From 5ad35a3107ca0443b81ada917b73b950d89bf396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 6 Oct 2023 11:30:41 -0700 Subject: [PATCH] Support resolving documentation links to content in other DocC archives (#710) This adds experimental support for resolving documentation links and symbol links to content in other DocC archives. At a high level this external link support works in two steps: 1. While building the documentation for the first module, DocC emits two files, one that describe the link hierarchy and its possible disambiguations and one that contains minimal amount of content for every linkable page. 2. Later, while building the documentation for the second module, DocC is passed the documentation archive for the first module and reads these file and uses them to resolve links to content in the first archive. > Important: There is more work needed to support this end-to-end. > > If you run this now you'll notice that the external links are 404s in the > browser unless you host both archives on the same server. rdar://114731067 --- .../ConvertOutputConsumer.swift | 4 + .../Infrastructure/DocumentationContext.swift | 44 +- .../DocumentationConverter.swift | 21 +- .../Infrastructure/DocumentationCurator.swift | 2 +- .../ExternalPathHierarchyResolver.swift | 261 ++++++ .../Link Resolution/LinkResolver.swift | 184 +++++ .../Link Resolution/PathHierarchy+Error.swift | 69 +- .../Link Resolution/PathHierarchy+Find.swift | 8 +- .../PathHierarchy+Serialization.swift | 183 +++++ .../Link Resolution/PathHierarchy.swift | 81 +- .../PathHierarchyBasedLinkResolver.swift | 146 +--- .../GeneratedDocumentationTopics.swift | 2 +- .../LinkTargets/LinkDestinationSummary.swift | 56 +- .../DocumentationContentRenderer.swift | 69 +- .../Rendering/RenderNodeTranslator.swift | 10 +- Sources/SwiftDocC/Utility/FeatureFlags.swift | 3 + .../Collection+indexed.swift | 16 + .../Actions/Convert/ConvertAction.swift | 17 +- .../Convert/ConvertFileWritingConsumer.swift | 19 +- .../ConvertAction+CommandInitialization.swift | 4 +- .../ArgumentParsing/Subcommands/Convert.swift | 92 ++- .../ExternalPathHierarchyResolverTests.swift | 741 ++++++++++++++++++ .../Infrastructure/PathHierarchyTests.swift | 62 +- .../SymbolDisambiguationTests.swift | 4 +- .../LinkDestinationSummaryTests.swift | 58 +- .../Rendering/RenderNodeTranslatorTests.swift | 11 +- .../TestRenderNodeOutputConsumer.swift | 1 + .../ConvertSubcommandTests.swift | 99 +++ features.json | 3 + 29 files changed, 1978 insertions(+), 292 deletions(-) create mode 100644 Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift create mode 100644 Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver.swift create mode 100644 Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Serialization.swift create mode 100644 Sources/SwiftDocC/Utility/FoundationExtensions/Collection+indexed.swift create mode 100644 Tests/SwiftDocCTests/Infrastructure/ExternalPathHierarchyResolverTests.swift diff --git a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift index 04e765c46e..11389feda4 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift @@ -46,6 +46,9 @@ public protocol ConvertOutputConsumer { /// Consumes build metadata created during a conversion. func consume(buildMetadata: BuildMetadata) throws + + /// Consumes a file representation of the local link resolution information. + func consume(linkResolutionInformation: SerializableLinkResolutionInformation) throws } // Default implementations that discard the documentation conversion products, for consumers that don't need these @@ -53,4 +56,5 @@ public protocol ConvertOutputConsumer { public extension ConvertOutputConsumer { func consume(renderReferenceStore: RenderReferenceStore) throws {} func consume(buildMetadata: BuildMetadata) throws {} + func consume(linkResolutionInformation: SerializableLinkResolutionInformation) throws {} } diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index b5a6831299..2b1afab093 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -110,11 +110,8 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { } } - /// A link resolver that resolves references by finding them in path hierarchy. - /// - /// The link resolver is `nil` until some documentation content is registered with the context. - /// It's safe to access the link resolver during symbol registration and at later points in the registration and conversion. - var hierarchyBasedLinkResolver: PathHierarchyBasedLinkResolver! = nil + /// A class that resolves documentation links by orchestrating calls to other link resolver implementations. + public var linkResolver = LinkResolver() /// The provider of documentation bundles for this context. var dataProvider: DocumentationContextDataProvider @@ -200,6 +197,8 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { /// A list of non-topic links that can be resolved. var nodeAnchorSections = [ResolvedTopicReference: AnchorSection]() + var externalCache = [ResolvedTopicReference: LinkResolver.ExternalEntity]() + /// A list of all the problems that was encountered while registering and processing the documentation bundles in this context. public var problems: [Problem] { return diagnosticEngine.problems @@ -361,7 +360,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { /// - dataProvider: The provider that removed this bundle. /// - bundle: The bundle that was removed. public func dataProvider(_ dataProvider: DocumentationContextDataProvider, didRemoveBundle bundle: DocumentationBundle) throws { - hierarchyBasedLinkResolver?.unregisterBundle(identifier: bundle.identifier) + linkResolver.localResolver?.unregisterBundle(identifier: bundle.identifier) // Purge the reference cache for this bundle and disable reference caching for // this bundle moving forward. @@ -1153,7 +1152,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { var moduleReferences = [String: ResolvedTopicReference]() // Build references for all symbols in all of this module's symbol graphs. - let symbolReferences = hierarchyBasedLinkResolver.referencesForSymbols(in: symbolGraphLoader.unifiedGraphs, bundle: bundle, context: self) + let symbolReferences = linkResolver.localResolver.referencesForSymbols(in: symbolGraphLoader.unifiedGraphs, bundle: bundle, context: self) // Set the index and cache storage capacity to avoid ad-hoc storage resizing. symbolIndex.reserveCapacity(symbolReferences.count) @@ -1270,7 +1269,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { // Only add the symbol mapping now if the path hierarchy based resolver is the main implementation. // If it is only used for mismatch checking then we must wait until the documentation cache code path has traversed and updated all the colliding nodes. // Otherwise the mappings will save the unmodified references and the hierarchy based resolver won't find the expected parent nodes when resolving links. - hierarchyBasedLinkResolver.addMappingForSymbols(symbolIndex: symbolIndex) + linkResolver.localResolver.addMappingForSymbols(symbolIndex: symbolIndex) // Track the symbols that have multiple matching documentation extension files for diagnostics. var symbolsWithMultipleDocumentationExtensionMatches = [ResolvedTopicReference: [SemanticResult
]]() @@ -1817,7 +1816,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { topicGraph.addNode(graphNode) documentationCache[reference] = documentation - hierarchyBasedLinkResolver.addRootArticle(article, anchorSections: documentation.anchorSections) + linkResolver.localResolver.addRootArticle(article, anchorSections: documentation.anchorSections) for anchor in documentation.anchorSections { nodeAnchorSections[anchor.reference] = anchor } @@ -1873,7 +1872,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { let graphNode = TopicGraph.Node(reference: reference, kind: .article, source: .file(url: article.source), title: title) topicGraph.addNode(graphNode) - hierarchyBasedLinkResolver.addArticle(article, anchorSections: documentation.anchorSections) + linkResolver.localResolver.addArticle(article, anchorSections: documentation.anchorSections) for anchor in documentation.anchorSections { nodeAnchorSections[anchor.reference] = anchor } @@ -2078,6 +2077,17 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { } } + discoveryGroup.async(queue: discoveryQueue) { [unowned self] in + do { + try linkResolver.loadExternalResolvers() + } catch { + // Pipe the error out of the dispatch queue. + discoveryError.sync({ + if $0 == nil { $0 = error } + }) + } + } + discoveryGroup.wait() try shouldContinueRegistration() @@ -2128,7 +2138,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { options = globalOptions.first } - self.hierarchyBasedLinkResolver = hierarchyBasedResolver + self.linkResolver.localResolver = hierarchyBasedResolver hierarchyBasedResolver.addMappingForRoots(bundle: bundle) for tutorial in tutorials { hierarchyBasedResolver.addTutorial(tutorial) @@ -2187,7 +2197,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { } try shouldContinueRegistration() - var allCuratedReferences = try crawlSymbolCuration(in: hierarchyBasedLinkResolver.topLevelSymbols(), bundle: bundle) + var allCuratedReferences = try crawlSymbolCuration(in: linkResolver.localResolver.topLevelSymbols(), bundle: bundle) // Store the list of manually curated references if doc coverage is on. if shouldStoreManuallyCuratedReferences { @@ -2222,7 +2232,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { // Emit warnings for any remaining uncurated files. emitWarningsForUncuratedTopics() - hierarchyBasedLinkResolver.addAnchorForSymbols(symbolIndex: symbolIndex, documentationCache: documentationCache) + linkResolver.localResolver.addAnchorForSymbols(symbolIndex: symbolIndex, documentationCache: documentationCache) // Fifth, resolve links in nodes that are added solely via curation try preResolveExternalLinks(references: Array(allCuratedReferences), bundle: bundle) @@ -2329,7 +2339,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { /// - Returns: An ordered list of symbol references that have been added to the topic graph automatically. private func autoCurateSymbolsInTopicGraph(engine: DiagnosticEngine) -> [(child: ResolvedTopicReference, parent: ResolvedTopicReference)] { var automaticallyCuratedSymbols = [(ResolvedTopicReference, ResolvedTopicReference)]() - hierarchyBasedLinkResolver.traverseSymbolAndParentPairs { reference, parentReference in + linkResolver.localResolver.traverseSymbolAndParentPairs { reference, parentReference in guard let topicGraphNode = topicGraph.nodeWithReference(reference), let topicGraphParentNode = topicGraph.nodeWithReference(parentReference), // Check that the node hasn't got any parents from manual curation @@ -2535,6 +2545,9 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { let referenceWithoutFragment = reference.withFragment(nil) return try entity(with: referenceWithoutFragment).availableSourceLanguages } catch ContextError.notFound { + if let externalEntity = externalCache[reference] { + return externalEntity.sourceLanguages + } preconditionFailure("Reference does not have an associated documentation node.") } catch { fatalError("Unexpected error when retrieving source languages: \(error)") @@ -2646,7 +2659,8 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { public func resolve(_ reference: TopicReference, in parent: ResolvedTopicReference, fromSymbolLink isCurrentlyResolvingSymbolLink: Bool = false) -> TopicReferenceResolutionResult { switch reference { case .unresolved(let unresolvedReference): - return hierarchyBasedLinkResolver.resolve(unresolvedReference, in: parent, fromSymbolLink: isCurrentlyResolvingSymbolLink, context: self) + return linkResolver.resolve(unresolvedReference, in: parent, fromSymbolLink: isCurrentlyResolvingSymbolLink, context: self) + case .resolved(let resolved): // This reference is already resolved (either as a success or a failure), so don't change anything. return resolved diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationConverter.swift b/Sources/SwiftDocC/Infrastructure/DocumentationConverter.swift index 2d45f9691a..4ef0f10f89 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationConverter.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationConverter.swift @@ -330,7 +330,7 @@ public struct DocumentationConverter: DocumentationConverterProtocol { } if emitDigest { - let nodeLinkSummaries = entity.externallyLinkableElementSummaries(context: context, renderNode: renderNode) + let nodeLinkSummaries = entity.externallyLinkableElementSummaries(context: context, renderNode: renderNode, includeTaskGroups: true) let nodeIndexingRecords = try renderNode.indexingRecords(onPage: identifier) resultsGroup.async(queue: resultsSyncQueue) { @@ -338,6 +338,12 @@ public struct DocumentationConverter: DocumentationConverterProtocol { linkSummaries.append(contentsOf: nodeLinkSummaries) indexingRecords.append(contentsOf: nodeIndexingRecords) } + } else if FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled { + let nodeLinkSummaries = entity.externallyLinkableElementSummaries(context: context, renderNode: renderNode, includeTaskGroups: false) + + resultsGroup.async(queue: resultsSyncQueue) { + linkSummaries.append(contentsOf: nodeLinkSummaries) + } } } catch { recordProblem(from: error, in: &results, withIdentifier: "render-node") @@ -362,6 +368,19 @@ public struct DocumentationConverter: DocumentationConverterProtocol { } } + if FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled { + do { + let serializableLinkInformation = try context.linkResolver.localResolver.prepareForSerialization(bundleID: bundle.identifier) + try outputConsumer.consume(linkResolutionInformation: serializableLinkInformation) + + if !emitDigest { + try outputConsumer.consume(linkableElementSummaries: linkSummaries) + } + } catch { + recordProblem(from: error, in: &conversionProblems, withIdentifier: "link-resolver") + } + } + if emitDigest { do { try outputConsumer.consume(problems: context.problems + conversionProblems) diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationCurator.swift b/Sources/SwiftDocC/Infrastructure/DocumentationCurator.swift index b0d93d1849..95c33090a0 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationCurator.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationCurator.swift @@ -115,7 +115,7 @@ struct DocumentationCurator { context.topicGraph.addNode(curatedNode) // Move the article from the article cache to the documentation - context.hierarchyBasedLinkResolver.addArticle(filename: articleFilename, reference: reference, anchorSections: documentationNode.anchorSections) + context.linkResolver.localResolver.addArticle(filename: articleFilename, reference: reference, anchorSections: documentationNode.anchorSections) context.documentationCache[reference] = documentationNode for anchor in documentationNode.anchorSections { diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift new file mode 100644 index 0000000000..7b8e5e600d --- /dev/null +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift @@ -0,0 +1,261 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2023 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import SymbolKit +import Markdown + +/// A class that resolves links to an already built documentation archives. +final class ExternalPathHierarchyResolver { + /// A hierarchy of path components used to resolve links in the documentation. + private(set) var pathHierarchy: PathHierarchy! + + /// A map from the path hierarchies identifiers to resolved references. + private var resolvedReferences = [ResolvedIdentifier: ResolvedTopicReference]() + /// A map from symbol's unique identifiers to their resolved references. + private var symbols: [String: ResolvedTopicReference] + + /// The content for each external entity. + private var content: [ResolvedTopicReference: LinkDestinationSummary] + + /// Attempts to resolve an unresolved reference. + /// + /// - Parameters: + /// - unresolvedReference: The unresolved reference to resolve. + /// - isCurrentlyResolvingSymbolLink: Whether or not the documentation link is a symbol link. + /// - Returns: The result of resolving the reference. + func resolve(_ unresolvedReference: UnresolvedTopicReference, fromSymbolLink isCurrentlyResolvingSymbolLink: Bool) -> TopicReferenceResolutionResult { + let originalReferenceString = Self.path(for: unresolvedReference) + do { + let foundID = try pathHierarchy.find(path: originalReferenceString, parent: nil, onlyFindSymbols: isCurrentlyResolvingSymbolLink) + guard let foundReference = resolvedReferences[foundID] else { + fatalError("Every identifier in the path hierarchy has a corresponding reference in the wrapping resolver. If it doesn't that's an indication that the file content that it was deserialized from was malformed.") + } + + guard content[foundReference] != nil else { + return .failure(unresolvedReference, .init("Resolved \(foundReference.url.withoutHostAndPortAndScheme().absoluteString.singleQuoted) but don't have any content to display for it.")) + } + + return .success(foundReference) + } catch let error as PathHierarchy.Error { + var originalReferenceString = unresolvedReference.path + if let fragment = unresolvedReference.topicURL.components.fragment { + originalReferenceString += "#" + fragment + } + + return .failure(unresolvedReference, error.asTopicReferenceResolutionErrorInfo(originalReference: originalReferenceString) { collidingNode in + self.fullName(of: collidingNode) // If the link was ambiguous, determine the full name of each colliding node to be presented in the link diagnostic. + }) + } catch { + fatalError("Only PathHierarchy.Error errors are raised from the symbol link resolution code above.") + } + } + + private func fullName(of collidingNode: PathHierarchy.Node) -> String { + guard let reference = resolvedReferences[collidingNode.identifier], let summary = content[reference] else { + return collidingNode.name + } + if let symbolID = collidingNode.symbol?.identifier { + if symbolID.interfaceLanguage == summary.language.id, let fragments = summary.declarationFragments { + return fragments.plainTextDeclaration() + } + if let variant = summary.variants.first(where: { $0.traits.contains(.interfaceLanguage(symbolID.interfaceLanguage)) }), + let fragments = variant.declarationFragments ?? summary.declarationFragments + { + return fragments.plainTextDeclaration() + } + } + return summary.title + } + + private static func path(for unresolved: UnresolvedTopicReference) -> String { + guard let fragment = unresolved.fragment else { + return unresolved.path + } + return "\(unresolved.path)#\(urlReadableFragment(fragment))" + } + + /// Returns the external entity for a symbol's unique identifier or `nil` if that symbol isn't known in this external context. + func entity(symbolID usr: String) -> ExternalEntity? { + // TODO: Resolve external symbols by USR (rdar://116085974) (There is nothing calling this function) + // This function has an optional return value since it's not easy to check what module a symbol belongs to based on its identifier. + guard let reference = symbols[usr] else { return nil } + return entity(reference) + } + + /// Returns the external entity for a reference that was successfully resolved by this external resolver. + /// + /// - Important: Passing a resolved reference that wasn't resolved by this resolver will result in a fatal error. + func entity(_ reference: ResolvedTopicReference) -> ExternalEntity { + guard let resolvedInformation = 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(bundleIdentifier: 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 + ) + } + + // MARK: Deserialization + + init( + linkInformation fileRepresentation: SerializableLinkResolutionInformation, + entityInformation linkDestinationSummaries: [LinkDestinationSummary] + ) { + // First, read the linkable entities and build up maps of USR -> Reference and Reference -> Content. + var entities = [ResolvedTopicReference: LinkDestinationSummary]() + var symbols = [String: ResolvedTopicReference]() + entities.reserveCapacity(linkDestinationSummaries.count) + symbols.reserveCapacity(linkDestinationSummaries.count) + for entity in linkDestinationSummaries { + let reference = ResolvedTopicReference( + bundleIdentifier: entity.referenceURL.host!, + path: entity.referenceURL.path, + fragment: entity.referenceURL.fragment, + sourceLanguage: entity.language + ) + entities[reference] = entity + if let usr = entity.usr { + symbols[usr] = reference + } + } + self.content = entities + self.symbols = symbols + + // Second, decode the path hierarchy + self.pathHierarchy = PathHierarchy(fileRepresentation.pathHierarchy) { identifiers in + // Third, iterate over the newly created path hierarchy's identifiers and build up the map from Identifier -> Reference. + self.resolvedReferences.reserveCapacity(identifiers.count) + for (index, path) in fileRepresentation.nonSymbolPaths { + guard let url = URL(string: path) else { + assertionFailure("Failed to create URL from \"\(path)\". This is an indication of an encoding issue.") + // In release builds, skip pages that failed to decode. It's possible that they're never linked to and that they won't cause any issue in the build. + continue + } + let identifier = identifiers[index] + self.resolvedReferences[identifier] = ResolvedTopicReference(bundleIdentifier: fileRepresentation.bundleID, path: url.path, fragment: url.fragment, sourceLanguage: .swift) + } + } + // Finally, the Identifier -> Symbol mapping can be constructed by iterating over the nodes and looking up the reference for each USR. + for (identifier, node) in self.pathHierarchy.lookup { + // The hierarchy contains both symbols and non-symbols so skip anything that isn't a symbol. + guard let usr = node.symbol?.identifier.precise else { continue } + self.resolvedReferences[identifier] = symbols[usr] + } + } + + convenience init(dependencyArchive: URL) throws { + // ???: Should it be the callers responsibility to pass both these URLs? + let linkHierarchyFile = dependencyArchive.appendingPathComponent("link-hierarchy.json") + let entityURL = dependencyArchive.appendingPathComponent("linkable-entities.json") + + self.init( + linkInformation: try JSONDecoder().decode(SerializableLinkResolutionInformation.self, from: Data(contentsOf: linkHierarchyFile)), + entityInformation: try JSONDecoder().decode([LinkDestinationSummary].self, from: Data(contentsOf: entityURL)) + ) + } +} + +private extension Sequence where Element == DeclarationRenderSection.Token { + func plainTextDeclaration() -> String { + return self.map(\.text).joined().split(whereSeparator: { $0.isWhitespace || $0.isNewline }).joined(separator: " ") + } +} + +// MARK: ExternalEntity + +extension ExternalPathHierarchyResolver { + /// The minimal information about an external entity necessary to render links to it on another page. + struct ExternalEntity { + /// 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 + + /// Create a topic content for be cached in a render reference store. + func topicContent() -> RenderReferenceStore.TopicContent { + return .init( + renderReference: topicRenderReference, + canonicalPath: nil, + taskGroups: nil, + source: nil, + isDocumentationExtensionContent: false, + renderReferenceDependencies: renderReferenceDependencies + ) + } + } +} + +private extension LinkDestinationSummary { + /// Create a topic render render reference for this link summary and its content variants. + func topicRenderReference() -> TopicRenderReference { + let (kind, role) = DocumentationContentRenderer.renderKindAndRole(kind, semantic: nil) + + var titleVariants = VariantCollection(defaultValue: title) + var abstractVariants = VariantCollection(defaultValue: abstract ?? []) + var fragmentVariants = VariantCollection(defaultValue: declarationFragments) + + for variant in variants { + let traits = variant.traits + if let title = variant.title { + titleVariants.variants.append(.init(traits: traits, patch: [.replace(value: title)])) + } + if let abstract = variant.abstract { + abstractVariants.variants.append(.init(traits: traits, patch: [.replace(value: abstract ?? [])])) + } + if let fragment = variant.declarationFragments { + fragmentVariants.variants.append(.init(traits: traits, patch: [.replace(value: fragment)])) + } + } + + return TopicRenderReference( + identifier: .init(referenceURL.absoluteString), + titleVariants: titleVariants, + abstractVariants: abstractVariants, + url: referenceURL.absoluteString, + kind: kind, + required: false, + role: role, + fragmentsVariants: fragmentVariants, + navigatorTitleVariants: .init(defaultValue: nil), + estimatedTime: nil, + conformance: nil, + isBeta: platforms?.contains(where: { $0.isBeta == true }) ?? false, + isDeprecated: platforms?.contains(where: { $0.unconditionallyDeprecated == true }) ?? false, + defaultImplementationCount: nil, + titleStyle: self.kind.isSymbol ? .symbol : .title, + name: title, + ideTitle: nil, + tags: nil, + images: topicImages ?? [] + ) + } +} diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver.swift new file mode 100644 index 0000000000..510ac88edf --- /dev/null +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver.swift @@ -0,0 +1,184 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2023 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import SymbolKit + +/// A class that resolves documentation links by orchestrating calls to other link resolver implementations. +public class LinkResolver { + /// A list of URLs to documentation archives that the local documentation depends on. + @_spi(ExternalLinks) // This needs to be public SPI so that the ConvertAction can set it. + public var dependencyArchives: [URL] = [] + + /// The link resolver to use to resolve links in the local bundle + var localResolver: PathHierarchyBasedLinkResolver! + /// A fallback resolver to use when the local resolver fails to resolve a link. + /// + /// This exist to preserve some behaviors for the convert service. + private let fallbackResolver = FallbackResolverBasedLinkResolver() + /// A map of link resolvers for external, already build archives + var externalResolvers: [String: ExternalPathHierarchyResolver] = [:] + + /// Create link resolvers for all documentation archive dependencies. + func loadExternalResolvers() throws { + let resolvers = try dependencyArchives.compactMap { + try ExternalPathHierarchyResolver(dependencyArchive: $0) + } + for resolver in resolvers { + for moduleName in resolver.pathHierarchy.modules.keys { + self.externalResolvers[moduleName] = resolver + } + } + } + + // ???: Should this be aliased the other way around? + typealias ExternalEntity = ExternalPathHierarchyResolver.ExternalEntity + + /// Attempts to resolve an unresolved reference. + /// + /// - Parameters: + /// - unresolvedReference: The unresolved reference to resolve. + /// - parent: The parent reference to resolve the unresolved reference relative to. + /// - isCurrentlyResolvingSymbolLink: Whether or not the documentation link is a symbol link. + /// - context: The documentation context to resolve the link in. + /// - Returns: The result of resolving the reference. + func resolve(_ unresolvedReference: UnresolvedTopicReference, in parent: ResolvedTopicReference, fromSymbolLink isCurrentlyResolvingSymbolLink: Bool, context: DocumentationContext) -> TopicReferenceResolutionResult { + // Check if the unresolved reference is external + if let bundleID = unresolvedReference.bundleIdentifier, + !context.registeredBundles.contains(where: { bundle in + bundle.identifier == bundleID || urlReadablePath(bundle.displayName) == bundleID + }) { + + if context.externalReferenceResolvers[bundleID] != nil, + let resolvedExternalReference = context.externallyResolvedLinks[unresolvedReference.topicURL] { + // Return the successful or failed externally resolved reference. + return resolvedExternalReference + } else if !context.registeredBundles.contains(where: { $0.identifier == bundleID }) { + return .failure(unresolvedReference, TopicReferenceResolutionErrorInfo("No external resolver registered for \(bundleID.singleQuoted).")) + } + } + + if let previousExternalResult = context.externallyResolvedLinks[unresolvedReference.topicURL] { + return previousExternalResult + } + + do { + return try localResolver.resolve(unresolvedReference, in: parent, fromSymbolLink: isCurrentlyResolvingSymbolLink, context: context) + } catch let error as PathHierarchy.Error { + // Check if there's a known external resolver for this module. + if case .moduleNotFound(let remainingPathComponents, _) = error, let resolver = externalResolvers[remainingPathComponents.first!.full] { + let result = resolver.resolve(unresolvedReference, fromSymbolLink: isCurrentlyResolvingSymbolLink) + context.externallyResolvedLinks[unresolvedReference.topicURL] = result + if case .success(let resolved) = result { + + context.externalCache[resolved] = resolver.entity(resolved) + } + return result + } + + // If the reference didn't resolve in the path hierarchy, see if it can be resolved in the fallback resolver. + if let resolvedFallbackReference = fallbackResolver.resolve(unresolvedReference, in: parent, fromSymbolLink: isCurrentlyResolvingSymbolLink, context: context) { + return .success(resolvedFallbackReference) + } else { + var originalReferenceString = unresolvedReference.path + if let fragment = unresolvedReference.topicURL.components.fragment { + originalReferenceString += "#" + fragment + } + + return .failure(unresolvedReference, error.asTopicReferenceResolutionErrorInfo(originalReference: originalReferenceString) { localResolver.fullName(of: $0, in: context) }) + } + } catch { + fatalError("Only SymbolPathTree.Error errors are raised from the symbol link resolution code above.") + } + } +} + +// MARK: Fallback resolver + +/// A fallback resolver that replicates the exact order of resolved topic references that are attempted to resolve via a fallback resolver when the path hierarchy doesn't have a match. +private final class FallbackResolverBasedLinkResolver { + var cachedResolvedFallbackReferences = Synchronized<[String: ResolvedTopicReference]>([:]) + + func resolve(_ unresolvedReference: UnresolvedTopicReference, in parent: ResolvedTopicReference, fromSymbolLink isCurrentlyResolvingSymbolLink: Bool, context: DocumentationContext) -> ResolvedTopicReference? { + // Check if a fallback reference resolver should resolve this + let referenceBundleIdentifier = unresolvedReference.bundleIdentifier ?? parent.bundleIdentifier + guard !context.fallbackReferenceResolvers.isEmpty, + let knownBundleIdentifier = context.registeredBundles.first(where: { $0.identifier == referenceBundleIdentifier || urlReadablePath($0.displayName) == referenceBundleIdentifier })?.identifier, + let fallbackResolver = context.fallbackReferenceResolvers[knownBundleIdentifier] + else { + return nil + } + + if let cached = cachedResolvedFallbackReferences.sync({ $0[unresolvedReference.topicURL.absoluteString] }) { + return cached + } + var allCandidateURLs = [URL]() + + let alreadyResolved = ResolvedTopicReference( + bundleIdentifier: referenceBundleIdentifier, + path: unresolvedReference.path.prependingLeadingSlash, + fragment: unresolvedReference.topicURL.components.fragment, + sourceLanguages: parent.sourceLanguages + ) + allCandidateURLs.append(alreadyResolved.url) + + let currentBundle = context.bundle(identifier: knownBundleIdentifier)! + if !isCurrentlyResolvingSymbolLink { + // First look up articles path + allCandidateURLs.append(contentsOf: [ + // First look up articles path + currentBundle.articlesDocumentationRootReference.url.appendingPathComponent(unresolvedReference.path), + // Then technology tutorials root path (for individual tutorial pages) + currentBundle.technologyTutorialsRootReference.url.appendingPathComponent(unresolvedReference.path), + // Then tutorials root path (for tutorial table of contents pages) + currentBundle.tutorialsRootReference.url.appendingPathComponent(unresolvedReference.path), + ]) + } + // Try resolving in the local context (as child) + allCandidateURLs.append(parent.appendingPathOfReference(unresolvedReference).url) + + // To look for siblings we require at least a module (first) + // and a symbol (second) path components. + let parentPath = parent.path.components(separatedBy: "/").dropLast() + if parentPath.count >= 2 { + allCandidateURLs.append(parent.url.deletingLastPathComponent().appendingPathComponent(unresolvedReference.path)) + } + + // Check that the parent is not an article (ignoring if absolute or relative link) + // because we cannot resolve in the parent context if it's not a symbol. + if parent.path.hasPrefix(currentBundle.documentationRootReference.path) && parentPath.count > 2 { + let rootPath = currentBundle.documentationRootReference.appendingPath(parentPath[2]) + let resolvedInRoot = rootPath.url.appendingPathComponent(unresolvedReference.path) + + // Confirm here that we we're not already considering this link. We only need to specifically + // consider the parent reference when looking for deeper links. + if resolvedInRoot.path != allCandidateURLs.last?.path { + allCandidateURLs.append(resolvedInRoot) + } + } + + allCandidateURLs.append(currentBundle.documentationRootReference.url.appendingPathComponent(unresolvedReference.path)) + + for candidateURL in allCandidateURLs { + if let cached = cachedResolvedFallbackReferences.sync({ $0[candidateURL.absoluteString] }) { + return cached + } + let unresolvedReference = UnresolvedTopicReference(topicURL: ValidatedURL(candidateURL)!) + let reference = fallbackResolver.resolve(.unresolved(unresolvedReference), sourceLanguage: parent.sourceLanguage) + + if case .success(let resolvedReference) = reference { + cachedResolvedFallbackReferences.sync({ $0[resolvedReference.absoluteString] = resolvedReference }) + return resolvedReference + } + } + // Give up: there is no local or external document for this reference. + return nil + } +} diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Error.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Error.swift index 24209a4ef1..8f350b89e9 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Error.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Error.swift @@ -29,6 +29,13 @@ extension PathHierarchy { /// - A list of the names for the top level elements. case notFound(remaining: [PathComponent], availableChildren: Set) + /// No element was found at the beginning of an absolute path. + /// + /// Includes information about: + /// - The remaining portion of the path. + /// - A list of the names for the available modules. + case moduleNotFound(remaining: [PathComponent], availableChildren: Set) + /// Matched node does not correspond to a documentation page. /// /// For partial symbol graph files, sometimes sparse nodes that don't correspond to known documentation need to be created to form a hierarchy. These nodes are not findable. @@ -69,21 +76,43 @@ extension PathHierarchy.Error { /// The resulting ``TopicReferenceResolutionError`` is human-readable and provides helpful solutions. /// /// - Parameters: - /// - context: The ``DocumentationContext`` the `originalReference` was resolved in. - /// - originalReference: The raw input string that represents the body of the reference that failed to resolve. This string is - /// used to calculate the proper replacement-ranges for fixits. + /// - originalReference: The raw input string that represents the body of the reference that failed to resolve. This string is used to calculate the proper replacement-ranges for fixits. + /// - fullNameOfNode: A closure that determines the full name of a node, to be displayed in collision diagnostics to precisely identify symbols and other pages. /// /// - Note: `Replacement`s produced by this function use `SourceLocation`s relative to the `originalReference`, i.e. the beginning /// of the _body_ of the original reference. - func asTopicReferenceResolutionErrorInfo(context: DocumentationContext, originalReference: String) -> TopicReferenceResolutionErrorInfo { - - // This is defined inline because it captures `context`. + func asTopicReferenceResolutionErrorInfo(originalReference: String, fullNameOfNode: (PathHierarchy.Node) -> String) -> TopicReferenceResolutionErrorInfo { + // This is defined inline because it captures `fullNameOfNode`. func collisionIsBefore(_ lhs: (node: PathHierarchy.Node, disambiguation: String), _ rhs: (node: PathHierarchy.Node, disambiguation: String)) -> Bool { - return lhs.node.fullNameOfValue(context: context) + lhs.disambiguation - < rhs.node.fullNameOfValue(context: context) + rhs.disambiguation + return fullNameOfNode(lhs.node) + lhs.disambiguation + < fullNameOfNode(rhs.node) + rhs.disambiguation } switch self { + case .moduleNotFound(remaining: let remaining, availableChildren: let availableChildren): + let firstPathComponent = remaining.first! // This would be a .notFound error if the remaining components were empty. + + let solutions: [Solution] + if let pathComponentIndex = originalReference.range(of: firstPathComponent.full) { + let startColumn = originalReference.distance(from: originalReference.startIndex, to: pathComponentIndex.lowerBound) + let replacementRange = SourceRange.makeRelativeRange(startColumn: startColumn, length: firstPathComponent.full.count) + + let nearMisses = NearMiss.bestMatches(for: availableChildren, against: firstPathComponent.name) + solutions = nearMisses.map { candidate in + Solution(summary: "\(Self.replacementOperationDescription(from: firstPathComponent.full, to: candidate))", replacements: [ + Replacement(range: replacementRange, replacement: candidate) + ]) + } + } else { + solutions = [] + } + + return TopicReferenceResolutionErrorInfo(""" + No module named \(firstPathComponent.full.singleQuoted) + """, + solutions: solutions + ) + case .notFound(remaining: let remaining, availableChildren: let availableChildren): guard let firstPathComponent = remaining.first else { return TopicReferenceResolutionErrorInfo( @@ -141,7 +170,7 @@ extension PathHierarchy.Error { let solutions: [Solution] = candidates .sorted(by: collisionIsBefore) .map { (node: PathHierarchy.Node, disambiguation: String) -> Solution in - return Solution(summary: "\(Self.replacementOperationDescription(from: disambiguations.dropFirst(), to: disambiguation)) for\n\(node.fullNameOfValue(context: context).singleQuoted)", replacements: [ + return Solution(summary: "\(Self.replacementOperationDescription(from: disambiguations.dropFirst(), to: disambiguation)) for\n\(fullNameOfNode(node).singleQuoted)", replacements: [ Replacement(range: replacementRange, replacement: "-" + disambiguation) ]) } @@ -206,7 +235,7 @@ extension PathHierarchy.Error { let replacementRange = SourceRange.makeRelativeRange(startColumn: validPrefix.count, length: disambiguations.count) let solutions: [Solution] = collisions.sorted(by: collisionIsBefore).map { (node: PathHierarchy.Node, disambiguation: String) -> Solution in - return Solution(summary: "\(Self.replacementOperationDescription(from: disambiguations.dropFirst(), to: disambiguation)) for\n\(node.fullNameOfValue(context: context).singleQuoted)", replacements: [ + return Solution(summary: "\(Self.replacementOperationDescription(from: disambiguations.dropFirst(), to: disambiguation)) for\n\(fullNameOfNode(node).singleQuoted)", replacements: [ Replacement(range: replacementRange, replacement: "-" + disambiguation) ]) } @@ -244,26 +273,6 @@ private extension PathHierarchy.Node { } return "/" + components.joined(separator: "/") } - - /// Determines the full name of a node's value using information from the documentation context. - /// - /// > Note: This value is only intended for error messages and other presentation. - func fullNameOfValue(context: DocumentationContext) -> String { - guard let identifier = identifier else { return name } - if let symbol = symbol { - if let fragments = symbol[mixin: SymbolGraph.Symbol.DeclarationFragments.self]?.declarationFragments { - return fragments.map(\.spelling).joined().split(whereSeparator: { $0.isWhitespace || $0.isNewline }).joined(separator: " ") - } - return context.nodeWithSymbolIdentifier(symbol.identifier.precise)!.name.description - } - // This only gets called for PathHierarchy error messages, so hierarchyBasedLinkResolver is never nil. - let reference = context.hierarchyBasedLinkResolver.resolvedReferenceMap[identifier]! - if reference.fragment != nil { - return context.nodeAnchorSections[reference]!.title - } else { - return context.documentationCache[reference]!.name.description - } - } } private extension SourceRange { diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Find.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Find.swift index 4ee41eb93d..9591412db2 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Find.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Find.swift @@ -112,6 +112,7 @@ extension PathHierarchy { // These errors are all more specific than a module-not-found error would be. case .unfindableMatch, + .moduleNotFound, .nonSymbolMatchForSymbolLink, .unknownDisambiguation, .lookupCollision: @@ -120,7 +121,12 @@ extension PathHierarchy { } } let topLevelNames = Set(modules.keys + [articlesContainer.name, tutorialContainer.name]) - throw Error.notFound(remaining: Array(remaining), availableChildren: topLevelNames) + + if isAbsolute, FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled { + throw Error.moduleNotFound(remaining: Array(remaining), availableChildren: Set(modules.keys)) + } else { + throw Error.notFound(remaining: Array(remaining), availableChildren: topLevelNames) + } } // A recursive function to traverse up the path hierarchy searching for the matching node diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Serialization.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Serialization.swift new file mode 100644 index 0000000000..da31dd07f3 --- /dev/null +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Serialization.swift @@ -0,0 +1,183 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2023 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import SymbolKit + +// MARK: PathHierarchy + +extension PathHierarchy.FileRepresentation { + // This mapping closure exist so that we don't encode ResolvedIdentifier values into the file. They're an implementation detail and they are a not stable across executions. + + /// Encode a path hierarchy into a file representation. + /// + /// The caller can use `mapCreatedIdentifiers` when encoding and decoding path hierarchies to associate auxiliary data with a node in the hierarchy. + /// + /// - Parameters: + /// - fileRepresentation: A path hierarchy to encode. + /// - mapCreatedIdentifiers: A closure that the caller can use to map indices to resolved identifiers. + init( + _ pathHierarchy: PathHierarchy, + mapCreatedIdentifiers: (_ identifiers: [ResolvedIdentifier]) -> Void + ) { + let lookup = pathHierarchy.lookup + + // Map each identifier to a number which will be used as to reference other nodes in the file representation. + var identifierMap = [ResolvedIdentifier: Int]() + identifierMap.reserveCapacity(lookup.count) + for (index, identifier) in zip(0..., lookup.keys) { + identifierMap[identifier] = index + } + + let nodes = [Node](unsafeUninitializedCapacity: lookup.count) { buffer, initializedCount in + for node in lookup.values { + buffer.initializeElement( + at: identifierMap[node.identifier]!, + to: Node( + name: node.name, + isDisfavoredInCollision: node.isDisfavoredInCollision, + children: node.children.values.flatMap({ tree in + var disambiguations = [Node.Disambiguation]() + for (kind, kindTree) in tree.storage { + for (hash, childNode) in kindTree where childNode.identifier != nil { // nodes without identifiers can't be found in the tree + disambiguations.append(.init(kind: kind, hash: hash, nodeID: identifierMap[childNode.identifier]!)) + } + } + return disambiguations + }), + symbolID: node.symbol?.identifier + ) + ) + } + initializedCount = lookup.count + } + + self.nodes = nodes + self.modules = pathHierarchy.modules.mapValues({ identifierMap[$0.identifier]! }) + self.articlesContainer = identifierMap[pathHierarchy.articlesContainer.identifier]! + self.tutorialContainer = identifierMap[pathHierarchy.tutorialContainer.identifier]! + self.tutorialOverviewContainer = identifierMap[pathHierarchy.tutorialOverviewContainer.identifier]! + + mapCreatedIdentifiers(Array(lookup.keys)) + } +} + +#if swift(<5.8) +// This makes 'initializeElement(at:to:)' available before Swift 5.8. +// Proposal: https://github.com/apple/swift-evolution/blob/main/proposals/0370-pointer-family-initialization-improvements.md +// Implementation: https://github.com/apple/swift/blob/main/stdlib/public/core/UnsafeBufferPointer.swift.gyb#L1031 +private extension UnsafeMutableBufferPointer { + func initializeElement(at index: UnsafeMutableBufferPointer.Index, to value: Element) { + assert(startIndex <= index && index < endIndex) + let p = baseAddress!.advanced(by: index) + p.initialize(to: value) + } +} +#endif + +extension PathHierarchy { + /// A file representation of a path hierarchy. + /// + /// The file representation can be decoded in later documentation builds to resolve external links to the content where the link resolver was originally created for. + struct FileRepresentation: Codable { + /// All the nodes in the hierarchy. + /// + /// Other places in the file hierarchy references nodes by their index in this list. + var nodes: [Node] + + /// The module nodes in this hierarchy. + var modules: [String: Int] + /// The container for articles and reference documentation. + var articlesContainer: Int + /// The container of tutorials. + var tutorialContainer: Int + /// The container of tutorial overview pages. + var tutorialOverviewContainer: Int + + /// A node in the + struct Node: Codable { + var name: String + var isDisfavoredInCollision: Bool = false + var children: [Disambiguation] = [] + var symbolID: SymbolGraph.Symbol.Identifier? + + struct Disambiguation: Codable { + var kind: String? + var hash: String? + var nodeID: Int + } + } + } +} + +extension PathHierarchy.FileRepresentation.Node { + enum CodingKeys: String, CodingKey { + case name + case isDisfavoredInCollision = "disfavored" + case children + case symbolID + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.name = try container.decode(String.self, forKey: .name) + self.isDisfavoredInCollision = try container.decodeIfPresent(Bool.self, forKey: .isDisfavoredInCollision) ?? false + self.children = try container.decodeIfPresent([Disambiguation].self, forKey: .children) ?? [] + self.symbolID = try container.decodeIfPresent(SymbolGraph.Symbol.Identifier.self, forKey: .symbolID) + } + + func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(self.name, forKey: .name) + try container.encodeIfTrue(self.isDisfavoredInCollision, forKey: .isDisfavoredInCollision) + try container.encodeIfNotEmpty(self.children, forKey: .children) + try container.encodeIfPresent(symbolID, forKey: .symbolID) + } +} + +// MARK: PathHierarchyBasedLinkResolver + +/// An opaque container of link resolution information that can be encoded and decoded. +/// +/// > Note: This format is not stable yet. Expect information to be significantly reorganized, added, and removed. +public struct SerializableLinkResolutionInformation: Codable { + // This type is public so that it can be an argument to a function in `ConvertOutputConsumer` + + var version: SemanticVersion + var bundleID: String + var pathHierarchy: PathHierarchy.FileRepresentation + // Separate storage of node data because the path hierarchy doesn't know the resolved references for articles. + var nonSymbolPaths: [Int: String] +} + +extension PathHierarchyBasedLinkResolver { + /// Create a file representation of the link resolver. + /// + /// The file representation can be decoded in later documentation builds to resolve external links to the content where the link resolver was originally created for. + func prepareForSerialization(bundleID: String) throws -> SerializableLinkResolutionInformation { + var nonSymbolPaths: [Int: String] = [:] + let hierarchyFileRepresentation = PathHierarchy.FileRepresentation(pathHierarchy) { identifiers in + nonSymbolPaths.reserveCapacity(identifiers.count) + for (index, identifier) in zip(0..., identifiers) where pathHierarchy.lookup[identifier]?.symbol == nil { + // Encode the resolved reference for all non-symbols. + nonSymbolPaths[index] = resolvedReferenceMap[identifier]!.url.withoutHostAndPortAndScheme().absoluteString + } + } + + return SerializableLinkResolutionInformation( + version: .init(major: 0, minor: 0, patch: 1), // This is still in development + bundleID: bundleID, + pathHierarchy: hierarchyFileRepresentation, + nonSymbolPaths: nonSymbolPaths + ) + } +} diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift index 68c22419e8..9668dae83a 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift @@ -93,7 +93,7 @@ struct PathHierarchy { accessLevel: SymbolGraph.Symbol.AccessControl(rawValue: "public"), kind: SymbolGraph.Symbol.Kind(parsedIdentifier: .module, displayName: moduleKindDisplayName), mixins: [:]) - let newModuleNode = Node(symbol: moduleSymbol) + let newModuleNode = Node(symbol: moduleSymbol, name: moduleName) roots[moduleName] = newModuleNode moduleNode = newModuleNode allNodes[moduleName] = [moduleNode] @@ -105,7 +105,8 @@ struct PathHierarchy { if let existingNode = allNodes[id]?.first(where: { $0.symbol!.identifier == symbol.identifier }) { nodes[id] = existingNode } else { - let node = Node(symbol: symbol) + assert(!symbol.pathComponents.isEmpty, "A symbol should have at least its own name in its path components.") + let node = Node(symbol: symbol, name: symbol.pathComponents.last!) // Disfavor synthesized symbols when they collide with other symbol with the same path. // FIXME: Get information about synthesized symbols from SymbolKit https://github.com/apple/swift-docc-symbolkit/issues/58 node.isDisfavoredInCollision = symbol.identifier.precise.contains("::SYNTHESIZED::") @@ -339,9 +340,9 @@ extension PathHierarchy { var isDisfavoredInCollision: Bool /// Initializes a symbol node. - fileprivate init(symbol: SymbolGraph.Symbol!) { + fileprivate init(symbol: SymbolGraph.Symbol!, name: String) { self.symbol = symbol - self.name = symbol.pathComponents.last! + self.name = name self.children = [:] self.isDisfavoredInCollision = false } @@ -366,6 +367,7 @@ extension PathHierarchy { /// Adds a descendant of this node. fileprivate func add(child: Node, kind: String?, hash: String?) { + // If the name was passed explicitly, then the node could have spaces in its name child.parent = self children[child.name, default: .init()].add(kind ?? "_", hash ?? "_", child) } @@ -469,6 +471,77 @@ extension PathHierarchy.DisambiguationContainer { } } +// MARK: Deserialization + +extension PathHierarchy { + // This is defined in the main PathHierarchy.swift file to access fileprivate properties and PathHierarchy.Node API without making it internally visible. + + // This mapping closure exist so that we don't encode ResolvedIdentifier values into the file. They're an implementation detail and they are a not stable across executions. + + /// Decode a path hierarchy from its file representation. + /// + /// The caller can use `mapCreatedIdentifiers` when encoding and decoding path hierarchies to associate auxiliary data with a node in the hierarchy. + /// + /// - Parameters: + /// - fileRepresentation: A file representation to decode. + /// - mapCreatedIdentifiers: A closure that the caller can use to map indices to resolved identifiers. + init( + _ fileRepresentation: FileRepresentation, + mapCreatedIdentifiers: (_ identifiers: [ResolvedIdentifier]) -> Void + ) { + // Generate new identifiers. While building the path hierarchy, the node numbers map to identifiers via index lookup in this array. + var identifiers = [ResolvedIdentifier]() + identifiers.reserveCapacity(fileRepresentation.nodes.count) + for _ in fileRepresentation.nodes.indices { + identifiers.append(ResolvedIdentifier()) + } + + var lookup = [ResolvedIdentifier: Node]() + lookup.reserveCapacity(fileRepresentation.nodes.count) + // Iterate once to create all the nodes + for (index, fileNode) in zip(0..., fileRepresentation.nodes) { + let node: Node + if let symbolID = fileNode.symbolID { + // Symbols decoded from a file representation only need an accurate ID. The rest of the information is never read and can be left empty. + let symbol = SymbolGraph.Symbol( + identifier: symbolID, + names: .init(title: "", navigator: nil, subHeading: nil, prose: nil), + pathComponents: [], + docComment: nil, + accessLevel: .public, + kind: SymbolGraph.Symbol.Kind(rawIdentifier: "", displayName: ""), + mixins: [:] + ) + node = Node(symbol: symbol, name: fileNode.name) + } else { + node = Node(name: fileNode.name) + } + node.isDisfavoredInCollision = fileNode.isDisfavoredInCollision + node.identifier = identifiers[index] + lookup[node.identifier] = node + } + // Iterate again to construct the tree + for (index, fileNode) in fileRepresentation.nodes.indexed() { + let node = lookup[identifiers[index]]! + for child in fileNode.children { + let childNode = lookup[identifiers[child.nodeID]]! + // Even if this is a symbol node, explicitly pass the kind and hash disambiguation. + node.add(child: childNode, kind: child.kind, hash: child.hash) + } + } + + self.lookup = lookup + self.modules = fileRepresentation.modules.mapValues({ lookup[identifiers[$0]]! }) + self.articlesContainer = lookup[identifiers[fileRepresentation.articlesContainer]]! + self.tutorialContainer = lookup[identifiers[fileRepresentation.tutorialContainer]]! + self.tutorialOverviewContainer = lookup[identifiers[fileRepresentation.tutorialOverviewContainer]]! + + mapCreatedIdentifiers(identifiers) + } +} + +// MARK: Hierarchical symbol relationships + private extension SymbolGraph.Relationship.Kind { /// Whether or not this relationship kind forms a hierarchical relationship between the source and the target. var formsHierarchy: Bool { diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchyBasedLinkResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchyBasedLinkResolver.swift index 077a7ec887..10bdaa147d 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchyBasedLinkResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchyBasedLinkResolver.swift @@ -200,52 +200,35 @@ final class PathHierarchyBasedLinkResolver { /// - isCurrentlyResolvingSymbolLink: Whether or not the documentation link is a symbol link. /// - context: The documentation context to resolve the link in. /// - Returns: The result of resolving the reference. - public func resolve(_ unresolvedReference: UnresolvedTopicReference, in parent: ResolvedTopicReference, fromSymbolLink isCurrentlyResolvingSymbolLink: Bool, context: DocumentationContext) -> TopicReferenceResolutionResult { - // Check if the unresolved reference is external - if let bundleID = unresolvedReference.bundleIdentifier, - !context.registeredBundles.contains(where: { bundle in - bundle.identifier == bundleID || urlReadablePath(bundle.displayName) == bundleID - }) { - - if context.externalReferenceResolvers[bundleID] != nil, - let resolvedExternalReference = context.externallyResolvedLinks[unresolvedReference.topicURL] { - // Return the successful or failed externally resolved reference. - return resolvedExternalReference - } else if !context.registeredBundles.contains(where: { $0.identifier == bundleID }) { - return .failure(unresolvedReference, TopicReferenceResolutionErrorInfo("No external resolver registered for \(bundleID.singleQuoted).")) - } + func resolve(_ unresolvedReference: UnresolvedTopicReference, in parent: ResolvedTopicReference, fromSymbolLink isCurrentlyResolvingSymbolLink: Bool, context: DocumentationContext) throws -> TopicReferenceResolutionResult { + let parentID = resolvedReferenceMap[parent] + let found = try pathHierarchy.find(path: Self.path(for: unresolvedReference), parent: parentID, onlyFindSymbols: isCurrentlyResolvingSymbolLink) + guard let foundReference = resolvedReferenceMap[found] else { + // It's possible for the path hierarchy to find a symbol that the local build doesn't create a page for. Such symbols can't be linked to. + let simplifiedFoundPath = sequence(first: pathHierarchy.lookup[found]!, next: \.parent) + .map(\.name).reversed().joined(separator: "/") + return .failure(unresolvedReference, .init("\(simplifiedFoundPath.singleQuoted) has no page and isn't available for linking.")) } - do { - let parentID = resolvedReferenceMap[parent] - let found = try pathHierarchy.find(path: Self.path(for: unresolvedReference), parent: parentID, onlyFindSymbols: isCurrentlyResolvingSymbolLink) - guard let foundReference = resolvedReferenceMap[found] else { - // It's possible for the path hierarchy to find a symbol that the local build doesn't create a page for. Such symbols can't be linked to. - let simplifiedFoundPath = sequence(first: pathHierarchy.lookup[found]!, next: \.parent) - .map(\.name).reversed().joined(separator: "/") - return .failure(unresolvedReference, .init("\(simplifiedFoundPath.singleQuoted) has no page and isn't available for linking.")) - } - - return .success(foundReference) - } catch let error as PathHierarchy.Error { - // If the reference didn't resolve in the path hierarchy, see if it can be resolved in the fallback resolver. - if let resolvedFallbackReference = fallbackResolver.resolve(unresolvedReference, in: parent, fromSymbolLink: isCurrentlyResolvingSymbolLink, context: context) { - return .success(resolvedFallbackReference) - } else { - var originalReferenceString = unresolvedReference.path - if let fragment = unresolvedReference.topicURL.components.fragment { - originalReferenceString += "#" + fragment - } - - return .failure(unresolvedReference, error.asTopicReferenceResolutionErrorInfo(context: context, originalReference: originalReferenceString)) + return .success(foundReference) + } + + func fullName(of node: PathHierarchy.Node, in context: DocumentationContext) -> String { + guard let identifier = node.identifier else { return node.name } + if let symbol = node.symbol { + if let fragments = symbol.declarationFragments { + return fragments.map(\.spelling).joined().split(whereSeparator: { $0.isWhitespace || $0.isNewline }).joined(separator: " ") } - } catch { - fatalError("Only SymbolPathTree.Error errors are raised from the symbol link resolution code above.") + return symbol.names.title + } + let reference = resolvedReferenceMap[identifier]! + if reference.fragment != nil { + return context.nodeAnchorSections[reference]!.title + } else { + return context.documentationCache[reference]!.name.description } } - private let fallbackResolver = FallbackResolverBasedLinkResolver() - // MARK: Symbol reference creation /// Returns a map between symbol identifiers and topic references. @@ -285,88 +268,7 @@ final class PathHierarchyBasedLinkResolver { guard let reference = reference else { continue } result[symbol.defaultIdentifier] = reference } - } - return result - } -} - -/// A fallback resolver that replicates the exact order of resolved topic references that are attempted to resolve via a fallback resolver when the path hierarchy doesn't have a match. -private final class FallbackResolverBasedLinkResolver { - var cachedResolvedFallbackReferences = Synchronized<[String: ResolvedTopicReference]>([:]) - - func resolve(_ unresolvedReference: UnresolvedTopicReference, in parent: ResolvedTopicReference, fromSymbolLink isCurrentlyResolvingSymbolLink: Bool, context: DocumentationContext) -> ResolvedTopicReference? { - // Check if a fallback reference resolver should resolve this - let referenceBundleIdentifier = unresolvedReference.bundleIdentifier ?? parent.bundleIdentifier - guard !context.fallbackReferenceResolvers.isEmpty, - let knownBundleIdentifier = context.registeredBundles.first(where: { $0.identifier == referenceBundleIdentifier || urlReadablePath($0.displayName) == referenceBundleIdentifier })?.identifier, - let fallbackResolver = context.fallbackReferenceResolvers[knownBundleIdentifier] - else { - return nil - } - - if let cached = cachedResolvedFallbackReferences.sync({ $0[unresolvedReference.topicURL.absoluteString] }) { - return cached } - var allCandidateURLs = [URL]() - - let alreadyResolved = ResolvedTopicReference( - bundleIdentifier: referenceBundleIdentifier, - path: unresolvedReference.path.prependingLeadingSlash, - fragment: unresolvedReference.topicURL.components.fragment, - sourceLanguages: parent.sourceLanguages - ) - allCandidateURLs.append(alreadyResolved.url) - - let currentBundle = context.bundle(identifier: knownBundleIdentifier)! - if !isCurrentlyResolvingSymbolLink { - // First look up articles path - allCandidateURLs.append(contentsOf: [ - // First look up articles path - currentBundle.articlesDocumentationRootReference.url.appendingPathComponent(unresolvedReference.path), - // Then technology tutorials root path (for individual tutorial pages) - currentBundle.technologyTutorialsRootReference.url.appendingPathComponent(unresolvedReference.path), - // Then tutorials root path (for tutorial table of contents pages) - currentBundle.tutorialsRootReference.url.appendingPathComponent(unresolvedReference.path), - ]) - } - // Try resolving in the local context (as child) - allCandidateURLs.append(parent.appendingPathOfReference(unresolvedReference).url) - - // To look for siblings we require at least a module (first) - // and a symbol (second) path components. - let parentPath = parent.path.components(separatedBy: "/").dropLast() - if parentPath.count >= 2 { - allCandidateURLs.append(parent.url.deletingLastPathComponent().appendingPathComponent(unresolvedReference.path)) - } - - // Check that the parent is not an article (ignoring if absolute or relative link) - // because we cannot resolve in the parent context if it's not a symbol. - if parent.path.hasPrefix(currentBundle.documentationRootReference.path) && parentPath.count > 2 { - let rootPath = currentBundle.documentationRootReference.appendingPath(parentPath[2]) - let resolvedInRoot = rootPath.url.appendingPathComponent(unresolvedReference.path) - - // Confirm here that we we're not already considering this link. We only need to specifically - // consider the parent reference when looking for deeper links. - if resolvedInRoot.path != allCandidateURLs.last?.path { - allCandidateURLs.append(resolvedInRoot) - } - } - - allCandidateURLs.append(currentBundle.documentationRootReference.url.appendingPathComponent(unresolvedReference.path)) - - for candidateURL in allCandidateURLs { - if let cached = cachedResolvedFallbackReferences.sync({ $0[candidateURL.absoluteString] }) { - return cached - } - let unresolvedReference = UnresolvedTopicReference(topicURL: ValidatedURL(candidateURL)!) - let reference = fallbackResolver.resolve(.unresolved(unresolvedReference), sourceLanguage: parent.sourceLanguage) - - if case .success(let resolvedReference) = reference { - cachedResolvedFallbackReferences.sync({ $0[resolvedReference.absoluteString] = resolvedReference }) - return resolvedReference - } - } - // Give up: there is no local or external document for this reference. - return nil + return result } } diff --git a/Sources/SwiftDocC/Infrastructure/Symbol Graph/GeneratedDocumentationTopics.swift b/Sources/SwiftDocC/Infrastructure/Symbol Graph/GeneratedDocumentationTopics.swift index 718ac20820..94d14b1c9e 100644 --- a/Sources/SwiftDocC/Infrastructure/Symbol Graph/GeneratedDocumentationTopics.swift +++ b/Sources/SwiftDocC/Infrastructure/Symbol Graph/GeneratedDocumentationTopics.swift @@ -141,7 +141,7 @@ enum GeneratedDocumentationTopics { let inheritedSection = AutomaticTaskGroupSection(title: defaultImplementationGroupTitle, references: [collectionReference], renderPositionPreference: .bottom) symbol.automaticTaskGroupsVariants[trait]?.append(inheritedSection) } - context.hierarchyBasedLinkResolver.addTaskGroup(named: title, reference: collectionReference, to: parent) + context.linkResolver.localResolver.addTaskGroup(named: title, reference: collectionReference, to: parent) } } else { fatalError("createCollectionNode() should be used only to add nodes under symbols.") diff --git a/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift b/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift index 6e150b5323..ae60a85020 100644 --- a/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift +++ b/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift @@ -304,8 +304,13 @@ public extension DocumentationNode { /// - Parameters: /// - context: The context in which references that are found the node's content are resolved in. /// - renderNode: The render node representation of this documentation node. + /// - includeTaskGroups: Whether or not the link summaries should include task groups /// - Returns: The list of summary elements, with the node's summary as the first element. - func externallyLinkableElementSummaries(context: DocumentationContext, renderNode: RenderNode) -> [LinkDestinationSummary] { + func externallyLinkableElementSummaries( + context: DocumentationContext, + renderNode: RenderNode, + includeTaskGroups: Bool = true + ) -> [LinkDestinationSummary] { guard let bundle = context.bundle(identifier: reference.bundleIdentifier) else { // Don't return anything for external references that don't have a bundle in the context. return [] @@ -322,15 +327,19 @@ public extension DocumentationNode { } var taskGroupVariants: [[RenderNode.Variant.Trait]: [LinkDestinationSummary.TaskGroup]] = [:] - let taskGroups: [LinkDestinationSummary.TaskGroup] - switch kind { - case .tutorial, .tutorialArticle, .technology, .technologyOverview, .chapter, .volume, .onPageLandmark: - taskGroups = [.init(title: nil, identifiers: context.children(of: reference).map { $0.reference.absoluteString })] - default: - taskGroups = renderNode.topicSections.map { group in .init(title: group.title, identifiers: group.identifiers) } - for variant in renderNode.topicSectionsVariants.variants { - taskGroupVariants[variant.traits] = variant.applyingPatchTo(renderNode.topicSections).map { group in .init(title: group.title, identifiers: group.identifiers) } + let taskGroups: [LinkDestinationSummary.TaskGroup]? + if includeTaskGroups { + switch kind { + case .tutorial, .tutorialArticle, .technology, .technologyOverview, .chapter, .volume, .onPageLandmark: + taskGroups = [.init(title: nil, identifiers: context.children(of: reference).map { $0.reference.absoluteString })] + default: + taskGroups = renderNode.topicSections.map { group in .init(title: group.title, identifiers: group.identifiers) } + for variant in renderNode.topicSectionsVariants.variants { + taskGroupVariants[variant.traits] = variant.applyingPatchTo(renderNode.topicSections).map { group in .init(title: group.title, identifiers: group.identifiers) } + } } + } else { + taskGroups = nil } return [ LinkDestinationSummary( @@ -374,7 +383,7 @@ extension LinkDestinationSummary { documentationNode: DocumentationNode, renderNode: RenderNode, relativePresentationURL: URL, - taskGroups: [TaskGroup], + taskGroups: [TaskGroup]?, taskGroupVariants: [[RenderNode.Variant.Trait]: [TaskGroup]], platforms: [PlatformAvailability]?, compiler: inout RenderContentCompiler @@ -426,9 +435,7 @@ extension LinkDestinationSummary { let abstract = renderSymbolAbstract(symbol.abstractVariants[summaryTrait] ?? symbol.abstract) let usr = symbol.externalIDVariants[summaryTrait] ?? symbol.externalID - let declaration = (symbol.subHeadingVariants[summaryTrait] ?? symbol.subHeading).map { subHeading in - subHeading.map { DeclarationRenderSection.Token(fragment: $0, identifier: nil) } - } + let declaration = (symbol.declarationVariants[summaryTrait] ?? symbol.declaration).renderDeclarationTokens() let language = documentationNode.sourceLanguage let variants: [Variant] = documentationNode.availableVariantTraits.compactMap { trait in @@ -437,9 +444,7 @@ extension LinkDestinationSummary { return nil } - let declarationVariant = symbol.subHeadingVariants[trait].map { subHeading in - subHeading.map { DeclarationRenderSection.Token(fragment: $0, identifier: nil) } - } + let declarationVariant = symbol.declarationVariants[trait]?.renderDeclarationTokens() let abstractVariant: Variant.VariantValue = symbol.abstractVariants[trait].map { renderSymbolAbstract($0) } @@ -486,6 +491,25 @@ extension LinkDestinationSummary { } } +private extension Dictionary where Key == [PlatformName?], Value == SymbolGraph.Symbol.DeclarationFragments { + func mainRenderFragments() -> SymbolGraph.Symbol.DeclarationFragments? { + guard count > 1 else { + return first?.value + } + + return self.min(by: { lhs, rhs in + // Join all the platform IDs and use that to get a stable value + lhs.key.compactMap(\.?.rawValue).joined() < lhs.key.compactMap(\.?.rawValue).joined() + })?.value + } + + func renderDeclarationTokens() -> [DeclarationRenderSection.Token]? { + return mainRenderFragments()?.declarationFragments.map { + DeclarationRenderSection.Token(fragment: $0, identifier: nil) + } + } +} + extension LinkDestinationSummary { /// Creates a link destination summary for a landmark on a page. diff --git a/Sources/SwiftDocC/Model/Rendering/DocumentationContentRenderer.swift b/Sources/SwiftDocC/Model/Rendering/DocumentationContentRenderer.swift index 7b3bf59682..360b0d7782 100644 --- a/Sources/SwiftDocC/Model/Rendering/DocumentationContentRenderer.swift +++ b/Sources/SwiftDocC/Model/Rendering/DocumentationContentRenderer.swift @@ -126,7 +126,7 @@ public class DocumentationContentRenderer { } /// Returns a metadata role for an article, depending if it's a collection, technology, or a free form article. - func roleForArticle(_ article: Article, nodeKind: DocumentationNode.Kind) -> RenderMetadata.Role { + static func roleForArticle(_ article: Article, nodeKind: DocumentationNode.Kind) -> RenderMetadata.Role { // If the article has a `@PageKind` directive, use the kind from there // before checking anything else. if let pageKind = article.metadata?.pageKind { @@ -152,7 +152,7 @@ public class DocumentationContentRenderer { } /// Returns a metadata role for the given documentation node kind. - func role(for kind: DocumentationNode.Kind) -> RenderMetadata.Role { + static func role(for kind: DocumentationNode.Kind) -> RenderMetadata.Role { switch kind { // A list of special node kinds to map to predefined roles case .article: return .article @@ -245,6 +245,35 @@ public class DocumentationContentRenderer { return true } + static func renderKindAndRole(_ kind: DocumentationNode.Kind?, semantic: Semantic?) -> (RenderNode.Kind, String) { + guard let kind = kind else { + return (.article, role(for: .article).rawValue) + } + let role = role(for: kind).rawValue + + switch kind { + case .tutorial: + return (.tutorial, role) + case .tutorialArticle: + return (.article, role) + case .technology: + return (.overview, role) + case .onPageLandmark: + return (.section, role) + case .sampleCode: + return (.article, role) + case _ where kind.isSymbol: + return (.symbol, role) + + default: + if let article = semantic as? Article { + return (.article, roleForArticle(article, nodeKind: kind).rawValue) + } else { + return (.article, role) + } + } + } + /// Creates a render reference for the given topic reference. /// - Parameters: /// - reference: A documentation node topic reference. @@ -261,8 +290,6 @@ public class DocumentationContentRenderer { let resolver = LinkTitleResolver(context: documentationContext, source: reference.url) let titleVariants: DocumentationDataVariants - let kind: RenderNode.Kind - var referenceRole: String? let node = try? overridingDocumentationNode ?? documentationContext.entity(with: reference) if let node = node, let resolvedTitle = resolver.title(for: node) { @@ -281,37 +308,17 @@ public class DocumentationContentRenderer { // Some nodes are artificially inserted into the topic graph, // 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) + + return external.topicRenderReference } else { titleVariants = .init(defaultVariantValue: reference.absoluteString) } - switch node?.kind { - case .some(.tutorial): - kind = .tutorial - referenceRole = role(for: .tutorial).rawValue - case .some(.tutorialArticle): - kind = .article - referenceRole = role(for: .tutorialArticle).rawValue - case .some(.technology): - kind = .overview - referenceRole = role(for: .technology).rawValue - case .some(.onPageLandmark): - kind = .section - referenceRole = role(for: .onPageLandmark).rawValue - case .some(.sampleCode): - kind = .article - referenceRole = role(for: .sampleCode).rawValue - case let nodeKind? where nodeKind.isSymbol: - kind = .symbol - referenceRole = role(for: nodeKind).rawValue - case _ where node?.semantic is Article: - kind = .article - referenceRole = roleForArticle(node!.semantic as! Article, nodeKind: node!.kind).rawValue - default: - kind = .article - referenceRole = role(for: .article).rawValue - } - + let (kind, referenceRole) = Self.renderKindAndRole(node?.kind, semantic: node?.semantic) let referenceURL = reference.absoluteString // Topic render references require the URLs to be relative, even if they're external. diff --git a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift index a95995239a..cd602a15ba 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift @@ -151,7 +151,7 @@ public struct RenderNodeTranslator: SemanticVisitor { } node.metadata.title = tutorial.intro.title - node.metadata.role = contentRenderer.role(for: .tutorial).rawValue + node.metadata.role = DocumentationContentRenderer.role(for: .tutorial).rawValue collectedTopicReferences.append(contentsOf: hierarchyTranslator.collectedTopicReferences) @@ -386,7 +386,7 @@ public struct RenderNodeTranslator: SemanticVisitor { node.metadata.category = technology.name node.metadata.categoryPathComponent = identifier.url.lastPathComponent node.metadata.estimatedTime = totalEstimatedDuration(for: technology) - node.metadata.role = contentRenderer.role(for: .technology).rawValue + node.metadata.role = DocumentationContentRenderer.role(for: .technology).rawValue let documentationNode = try! context.entity(with: identifier) node.variants = variants(for: documentationNode) @@ -740,7 +740,7 @@ public struct RenderNodeTranslator: SemanticVisitor { // Set an eyebrow for articles node.metadata.roleHeading = "Article" } - node.metadata.role = contentRenderer.roleForArticle(article, nodeKind: documentationNode.kind).rawValue + node.metadata.role = DocumentationContentRenderer.roleForArticle(article, nodeKind: documentationNode.kind).rawValue } if let pageImages = documentationNode.metadata?.pageImages { @@ -881,7 +881,7 @@ public struct RenderNodeTranslator: SemanticVisitor { node.metadata.category = technology.name node.metadata.categoryPathComponent = hierarchy.technology.url.lastPathComponent - node.metadata.role = contentRenderer.role(for: .tutorialArticle).rawValue + node.metadata.role = DocumentationContentRenderer.role(for: .tutorialArticle).rawValue // Unlike for other pages, in here we use `RenderHierarchyTranslator` to crawl the technology // and produce the list of modules for the render hierarchy to display in the tutorial local navigation. @@ -1243,7 +1243,7 @@ public struct RenderNodeTranslator: SemanticVisitor { } node.metadata.requiredVariants = VariantCollection(from: symbol.isRequiredVariants) ?? .init(defaultValue: false) - node.metadata.role = contentRenderer.role(for: documentationNode.kind).rawValue + node.metadata.role = DocumentationContentRenderer.role(for: documentationNode.kind).rawValue node.metadata.titleVariants = VariantCollection(from: symbol.titleVariants) node.metadata.externalIDVariants = VariantCollection(from: symbol.externalIDVariants) diff --git a/Sources/SwiftDocC/Utility/FeatureFlags.swift b/Sources/SwiftDocC/Utility/FeatureFlags.swift index ed6fe38bb7..5e79769c48 100644 --- a/Sources/SwiftDocC/Utility/FeatureFlags.swift +++ b/Sources/SwiftDocC/Utility/FeatureFlags.swift @@ -35,6 +35,9 @@ public struct FeatureFlags: Codable { @available(*, deprecated, message: "Doxygen support is now enabled by default.") public var isExperimentalDoxygenSupportEnabled = false + /// Whether or not experimental support for emitting a serialized version of the local link resolution information is enabled. + public var isExperimentalLinkHierarchySerializationEnabled = false + /// Creates a set of feature flags with the given values. /// /// - Parameters: diff --git a/Sources/SwiftDocC/Utility/FoundationExtensions/Collection+indexed.swift b/Sources/SwiftDocC/Utility/FoundationExtensions/Collection+indexed.swift new file mode 100644 index 0000000000..1a3b30deef --- /dev/null +++ b/Sources/SwiftDocC/Utility/FoundationExtensions/Collection+indexed.swift @@ -0,0 +1,16 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2023 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 Collection { + /// Returns a sequence of pairs `(i, x)`, where `i` represents an index and `x` represents an element of the collection. + func indexed() -> Zip2Sequence { + zip(indices, self) + } +} diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift index ba76768614..b3a69f5ec1 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift @@ -9,7 +9,7 @@ */ import Foundation -import SwiftDocC +@_spi(ExternalLinks) import SwiftDocC // SPI to set `context.linkResolver.dependencyArchives` /// An action that converts a source bundle into compiled documentation. public struct ConvertAction: Action, RecreatingContext { @@ -110,7 +110,8 @@ public struct ConvertAction: Action, RecreatingContext { transformForStaticHosting: Bool = false, allowArbitraryCatalogDirectories: Bool = false, hostingBasePath: String? = nil, - sourceRepository: SourceRepository? = nil + sourceRepository: SourceRepository? = nil, + dependencies: [URL] = [] ) throws { self.rootURL = documentationBundleURL @@ -163,7 +164,8 @@ public struct ConvertAction: Action, RecreatingContext { self.context = try context ?? DocumentationContext(dataProvider: workspace, diagnosticEngine: engine) self.diagnosticLevel = filterLevel self.context.externalMetadata.diagnosticLevel = self.diagnosticLevel - + self.context.linkResolver.dependencyArchives = dependencies + // Inject current platform versions if provided if let currentPlatforms = currentPlatforms { self.context.externalMetadata.currentPlatforms = currentPlatforms @@ -251,7 +253,8 @@ public struct ConvertAction: Action, RecreatingContext { transformForStaticHosting: transformForStaticHosting, hostingBasePath: hostingBasePath, sourceRepository: sourceRepository, - temporaryDirectory: temporaryDirectory + temporaryDirectory: temporaryDirectory, + dependencies: [] ) } @@ -280,7 +283,8 @@ public struct ConvertAction: Action, RecreatingContext { allowArbitraryCatalogDirectories: Bool = false, hostingBasePath: String?, sourceRepository: SourceRepository? = nil, - temporaryDirectory: URL + temporaryDirectory: URL, + dependencies: [URL] = [] ) throws { // Note: This public initializer exists separately from the above internal one // because the FileManagerProtocol type we use to enable mocking in tests @@ -313,7 +317,8 @@ public struct ConvertAction: Action, RecreatingContext { transformForStaticHosting: transformForStaticHosting, allowArbitraryCatalogDirectories: allowArbitraryCatalogDirectories, hostingBasePath: hostingBasePath, - sourceRepository: sourceRepository + sourceRepository: sourceRepository, + dependencies: dependencies ) } diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift index 1d4d6c78fb..b4601f82a5 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift @@ -138,7 +138,7 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer { } func consume(linkableElementSummaries summaries: [LinkDestinationSummary]) throws { - let linkableElementsURL = targetFolder.appendingPathComponent("linkable-entities.json", isDirectory: false) + let linkableElementsURL = targetFolder.appendingPathComponent(Self.linkableEntitiesFileName, isDirectory: false) let data = try encode(summaries) try fileManager.createFile(at: linkableElementsURL, contents: data) } @@ -171,16 +171,23 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer { func consume(documentationCoverageInfo: [CoverageDataEntry]) throws { let data = try encode(documentationCoverageInfo) - let docCoverageURL = targetFolder.appendingPathComponent(ConvertFileWritingConsumer.docCoverageFileName, isDirectory: false) + let docCoverageURL = targetFolder.appendingPathComponent(Self.docCoverageFileName, isDirectory: false) try fileManager.createFile(at: docCoverageURL, contents: data) } func consume(buildMetadata: BuildMetadata) throws { let data = try encode(buildMetadata) - let buildMetadataURL = targetFolder.appendingPathComponent(ConvertFileWritingConsumer.buildMetadataFileName, isDirectory: false) + let buildMetadataURL = targetFolder.appendingPathComponent(Self.buildMetadataFileName, isDirectory: false) try fileManager.createFile(at: buildMetadataURL, contents: data) } + func consume(linkResolutionInformation: SerializableLinkResolutionInformation) throws { + let data = try encode(linkResolutionInformation) + let linkResolutionInfoURL = targetFolder.appendingPathComponent(Self.linkHierarchyFileName, isDirectory: false) + + try fileManager.createFile(at: linkResolutionInfoURL, contents: data) + } + /// Encodes the given value using the default render node JSON encoder. private func encode(_ value: E) throws -> Data { try RenderJSONEncoder.makeEncoder().encode(value) @@ -209,6 +216,12 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer { /// File name for the build metadata file emitted during conversion. static var buildMetadataFileName = "metadata.json" + + /// File name for the linkable entity file emitted during conversion. + static var linkableEntitiesFileName = "linkable-entities.json" + + /// File name for the link hierarchy file emitted during conversion. + static var linkHierarchyFileName = "link-hierarchy.json" } enum Digest { diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift index 03fa54e1fa..0281e99b55 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift @@ -21,6 +21,7 @@ extension ConvertAction { let outOfProcessResolver: OutOfProcessReferenceResolver? FeatureFlags.current.isExperimentalDeviceFrameSupportEnabled = convert.enableExperimentalDeviceFrameSupport + FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled = convert.enableExperimentalLinkHierarchySerialization // If the user-provided a URL for an external link resolver, attempt to // initialize an `OutOfProcessReferenceResolver` with the provided URL. @@ -86,7 +87,8 @@ extension ConvertAction { transformForStaticHosting: convert.transformForStaticHosting, allowArbitraryCatalogDirectories: convert.allowArbitraryCatalogDirectories, hostingBasePath: convert.hostingBasePath, - sourceRepository: SourceRepository(from: convert.sourceRepositoryArguments) + sourceRepository: SourceRepository(from: convert.sourceRepositoryArguments), + dependencies: convert.dependencies ) } } diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift index 2b179500f9..d20aebc138 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift @@ -25,6 +25,8 @@ extension Docc { /// Provided as a static variable to allow for redirecting output in unit tests. static var _errorLogHandle: LogHandle = .standardError + static var _diagnosticFormattingOptions: DiagnosticFormattingOptions = [] + public static var configuration = CommandConfiguration( abstract: "Convert documentation markup, assets, and symbol information into a documentation archive.", usage: """ @@ -206,7 +208,7 @@ extension Docc { /// /// This value defaults to true but can be explicitly disabled with the `--no-transform-for-static-hosting` flag. public var transformForStaticHosting: Bool { - get { hostingOptions.transformForStaticHosting} + get { hostingOptions.transformForStaticHosting } set { hostingOptions.transformForStaticHosting = newValue } } @@ -425,6 +427,78 @@ extension Docc { @OptionGroup(title: "Documentation coverage (Experimental)") public var experimentalDocumentationCoverageOptions: DocumentationCoverageOptionsArgument + // MARK: - Link resolution options + + @OptionGroup(title: "Link resolution options (Experimental)") + var linkResolutionOptions: LinkResolutionOptions + + struct LinkResolutionOptions: ParsableArguments { + @Option( + name: [.customLong("dependency")], + parsing: ArrayParsingStrategy.singleValue, + help: ArgumentHelp("A path to a documentation archive to resolve external links against.", discussion: """ + Only documentation archives built with '--enable-experimental-external-link-support' are supported as dependencies. + """), + transform: URL.init(fileURLWithPath:) + ) + var dependencies: [URL] = [] + + mutating func validate() throws { + let fileManager = FileManager.default + + var filteredDependencies: [URL] = [] + for dependency in dependencies { + // Check that the dependency URL is a directory. We don't validate the extension. + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: dependency.path, isDirectory: &isDirectory) else { + Convert.warnAboutDiagnostic(.init( + severity: .warning, + identifier: "org.swift.docc.Dependency.NotFound", + summary: "No documentation archive exist at '\(dependency.path)'." + )) + continue + } + guard isDirectory.boolValue else { + Convert.warnAboutDiagnostic(.init( + severity: .warning, + identifier: "org.swift.docc.Dependency.IsNotDirectory", + summary: "Dependency at '\(dependency.path)' is not a directory." + )) + continue + } + // Check that the dependency contains both the expected files + let linkableEntitiesFile = dependency.appendingPathComponent(ConvertFileWritingConsumer.linkableEntitiesFileName, isDirectory: false) + let hasLinkableEntitiesFile = fileManager.fileExists(atPath: linkableEntitiesFile.path) + if !hasLinkableEntitiesFile { + Convert.warnAboutDiagnostic(.init( + severity: .warning, + identifier: "org.swift.docc.Dependency.MissingLinkableEntities", + summary: "Dependency at '\(dependency.path)' doesn't contain a is not a '\(linkableEntitiesFile.lastPathComponent)' file." + )) + } + let linkableHierarchyFile = dependency.appendingPathComponent(ConvertFileWritingConsumer.linkHierarchyFileName, isDirectory: false) + let hasLinkableHierarchyFile = fileManager.fileExists(atPath: linkableHierarchyFile.path) + if !hasLinkableHierarchyFile { + Convert.warnAboutDiagnostic(.init( + severity: .warning, + identifier: "org.swift.docc.Dependency.MissingLinkHierarchy", + summary: "Dependency at '\(dependency.path)' doesn't contain a is not a '\(linkableHierarchyFile.lastPathComponent)' file." + )) + } + if hasLinkableEntitiesFile && hasLinkableHierarchyFile { + filteredDependencies.append(dependency) + } + } + self.dependencies = filteredDependencies + } + } + + /// A list of URLs to documentation archives that the local documentation depends on. + public var dependencies: [URL] { + get { linkResolutionOptions.dependencies } + set { linkResolutionOptions.dependencies = newValue } + } + // MARK: - Feature flag options @OptionGroup(title: "Feature flags") @@ -455,6 +529,14 @@ extension Docc { @Flag(help: "Experimental: allow catalog directories without the `.docc` extension.") var allowArbitraryCatalogDirectories = false + @Flag( + name: .customLong("enable-experimental-external-link-support"), + help: ArgumentHelp("Support external links to this documentation output.", discussion: """ + Write additional link metadata files to the output directory to support resolving documentation links to the documentation in that output directory. + """) + ) + var enableExperimentalLinkHierarchySerialization = false + @Flag(help: "Write additional metadata files to the output directory.") var emitDigest = false @@ -541,6 +623,12 @@ extension Docc { set { featureFlags.allowArbitraryCatalogDirectories = newValue } } + /// A user-provided value that is true if the user enables experimental serialization of the local link resolution information. + public var enableExperimentalLinkHierarchySerialization: Bool { + get { featureFlags.enableExperimentalLinkHierarchySerialization } + set { featureFlags.enableExperimentalLinkHierarchySerialization = newValue } + } + /// A user-provided value that is true if additional metadata files should be produced. /// /// Defaults to false. @@ -636,7 +724,7 @@ extension Docc { private static func warnAboutDiagnostic(_ diagnostic: Diagnostic) { print( - DiagnosticConsoleWriter.formattedDescription(for: diagnostic), + DiagnosticConsoleWriter.formattedDescription(for: diagnostic, options: _diagnosticFormattingOptions), to: &_errorLogHandle ) } diff --git a/Tests/SwiftDocCTests/Infrastructure/ExternalPathHierarchyResolverTests.swift b/Tests/SwiftDocCTests/Infrastructure/ExternalPathHierarchyResolverTests.swift new file mode 100644 index 0000000000..24eb76ab92 --- /dev/null +++ b/Tests/SwiftDocCTests/Infrastructure/ExternalPathHierarchyResolverTests.swift @@ -0,0 +1,741 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2023 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 Markdown +@testable import SwiftDocC + +class ExternalPathHierarchyResolverTests: XCTestCase { + + private var originalFeatureFlagsState: FeatureFlags! + + override func setUp() { + super.setUp() + originalFeatureFlagsState = FeatureFlags.current + FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled = true + } + + override func tearDown() { + FeatureFlags.current = originalFeatureFlagsState + originalFeatureFlagsState = nil + super.tearDown() + } + + // These tests resolve absolute symbol links in both a local and external context to verify that external links work the same local links. + + func testUnambiguousAbsolutePaths() throws { + let linkResolvers = try makeLinkResolversForTestBundle(named: "MixedLanguageFrameworkWithLanguageRefinements") + + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework") + + // @objc public enum MyEnum: Int { + // case firstCase + // case secondCase + // public func myEnumFunction() { } + // public typealias MyEnumTypeAlias = Int + // public var myEnumProperty: MyEnumTypeAlias { 0 } + // } + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyEnum") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyEnum/firstCase") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyEnum/secondCase") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyEnum/myEnumFunction()") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyEnum/MyEnumTypeAlias") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyEnum/myEnumProperty") + + // public struct MyStruct { + // public func myStructFunction() { } + // public typealias MyStructTypeAlias = Int + // public var myStructProperty: MyStructTypeAlias { 0 } + // public static var myStructTypeProperty: MyStructTypeAlias { 0 } + // } + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyStruct") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyStruct/myStructFunction()") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyStruct/MyStructTypeAlias") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyStruct/myStructProperty") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyStruct/myStructTypeProperty") + + // @objc public class MyClass: NSObject { + // @objc public func myInstanceMethod() { } + // @nonobjc public func mySwiftOnlyInstanceMethod() { } + // public typealias MyClassTypeAlias = Int + // public var myInstanceProperty: MyClassTypeAlias { 0 } + // public static var myClassTypeProperty: MyClassTypeAlias { 0 } + // } + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyClass") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyClass/myInstanceMethod()") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyClass/mySwiftOnlyInstanceMethod()") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyClass/MyClassTypeAlias") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyClass/myInstanceProperty") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyClass/myClassTypeProperty") + + // @objc public protocol MyObjectiveCCompatibleProtocol { + // func myProtocolMethod() + // typealias MyProtocolTypeAlias = MyClass + // var myProtocolProperty: MyProtocolTypeAlias { get } + // static var myProtocolTypeProperty: MyProtocolTypeAlias { get } + // @objc optional func myPropertyOptionalMethod() + // } + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCCompatibleProtocol") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCCompatibleProtocol/myProtocolMethod()") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCCompatibleProtocol/MyProtocolTypeAlias") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCCompatibleProtocol/myProtocolProperty") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCCompatibleProtocol/myProtocolTypeProperty") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCCompatibleProtocol/myPropertyOptionalMethod()") + + // public protocol MySwiftProtocol { + // func myProtocolMethod() + // associatedtype MyProtocolAssociatedType + // typealias MyProtocolTypeAlias = MyStruct + // var myProtocolProperty: MyProtocolAssociatedType { get } + // static var myProtocolTypeProperty: MyProtocolAssociatedType { get } + // } + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MySwiftProtocol") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MySwiftProtocol/myProtocolMethod()") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MySwiftProtocol/MyProtocolAssociatedType") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MySwiftProtocol/MyProtocolTypeAlias") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MySwiftProtocol/myProtocolProperty") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MySwiftProtocol/myProtocolTypeProperty") + + // public typealias MyTypeAlias = MyStruct + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyTypeAlias") + + // public func myTopLevelFunction() { } + // public var myTopLevelVariable = true + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/myTopLevelFunction()") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/myTopLevelVariable") + + // public protocol MyOtherProtocolThatConformToMySwiftProtocol: MySwiftProtocol { + // func myOtherProtocolMethod() + // } + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyOtherProtocolThatConformToMySwiftProtocol") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyOtherProtocolThatConformToMySwiftProtocol/myOtherProtocolMethod()") + + // @objcMembers public class MyClassThatConformToMyOtherProtocol: NSObject, MyOtherProtocolThatConformToMySwiftProtocol { + // public func myOtherProtocolMethod() { } + // public func myProtocolMethod() { } + // public typealias MyProtocolAssociatedType = MyStruct + // public var myProtocolProperty: MyStruct { .init() } + // public class var myProtocolTypeProperty: MyStruct { .init() } + // } + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyClassThatConformToMyOtherProtocol") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyClassThatConformToMyOtherProtocol/myOtherProtocolMethod()") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyClassThatConformToMyOtherProtocol/myProtocolMethod()") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyClassThatConformToMyOtherProtocol/MyProtocolAssociatedType") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyClassThatConformToMyOtherProtocol/myProtocolProperty") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyClassThatConformToMyOtherProtocol/myProtocolTypeProperty") + + // public final class CollisionsWithDifferentCapitalization { + // public var something: Int = 0 + // public var someThing: Int = 0 + // } + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/CollisionsWithDifferentCapitalization") + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/CollisionsWithDifferentCapitalization/something", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/CollisionsWithDifferentCapitalization/something-2c4k6" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/CollisionsWithDifferentCapitalization/someThing", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/CollisionsWithDifferentCapitalization/someThing-90i4h" + ) + + // public enum CollisionsWithDifferentKinds { + // case something + // public var something: String { "" } + // public typealias Something = Int + // } + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/CollisionsWithDifferentKinds") + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/CollisionsWithDifferentKinds/something-enum.case", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/CollisionsWithDifferentKinds/something-swift.enum.case" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/CollisionsWithDifferentKinds/something-property", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/CollisionsWithDifferentKinds/something-swift.property" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/CollisionsWithDifferentKinds/Something", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/CollisionsWithDifferentKinds/Something-swift.typealias" + ) + + // public final class CollisionsWithEscapedKeywords { + // public subscript() -> Int { 0 } + // public func `subscript`() { } + // public static func `subscript`() { } + // + // public init() { } + // public func `init`() { } + // public static func `init`() { } + // } + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/CollisionsWithEscapedKeywords") + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/CollisionsWithEscapedKeywords/init()-init", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/CollisionsWithEscapedKeywords/init()-swift.init" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/CollisionsWithEscapedKeywords/init()-method", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/CollisionsWithEscapedKeywords/init()-swift.method" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/CollisionsWithEscapedKeywords/init()-type.method", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/CollisionsWithEscapedKeywords/init()-swift.type.method" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/CollisionsWithEscapedKeywords/subscript()-subscript", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/CollisionsWithEscapedKeywords/subscript()-swift.subscript" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/CollisionsWithEscapedKeywords/subscript()-method", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/CollisionsWithEscapedKeywords/subscript()-swift.method" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/CollisionsWithEscapedKeywords/subscript()-type.method", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/CollisionsWithEscapedKeywords/subscript()-swift.type.method" + ) + + // public enum CollisionsWithDifferentFunctionArguments { + // public func something(argument: Int) -> Int { 0 } + // public func something(argument: String) -> Int { 0 } + // } + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/CollisionsWithDifferentFunctionArguments") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/CollisionsWithDifferentFunctionArguments/something(argument:)-1cyvp") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/CollisionsWithDifferentFunctionArguments/something(argument:)-2vke2") + + // public enum CollisionsWithDifferentSubscriptArguments { + // public subscript(something: Int) -> Int { 0 } + // public subscript(somethingElse: String) -> Int { 0 } + // } + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/CollisionsWithDifferentSubscriptArguments") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/CollisionsWithDifferentSubscriptArguments/subscript(_:)-4fd0l") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/CollisionsWithDifferentSubscriptArguments/subscript(_:)-757cj") + + // @objc(MySwiftClassObjectiveCName) + // public class MySwiftClassSwiftName: NSObject { + // @objc(myPropertyObjectiveCName) + // public var myPropertySwiftName: Int { 0 } + // + // @objc(myMethodObjectiveCName) + // public func myMethodSwiftName() -> Int { 0 } + // } + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MySwiftClassSwiftName") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MySwiftClassSwiftName/myPropertySwiftName") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MySwiftClassSwiftName/myMethodSwiftName()") + + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MySwiftClassObjectiveCName", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MySwiftClassSwiftName" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MySwiftClassObjectiveCName/myPropertyObjectiveCName", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MySwiftClassSwiftName/myPropertySwiftName" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MySwiftClassObjectiveCName/myMethodObjectiveCName", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MySwiftClassSwiftName/myMethodSwiftName()" + ) + + // NS_SWIFT_NAME(MyObjectiveCClassSwiftName) + // @interface MyObjectiveCClassObjectiveCName : NSObject + // + // @property (copy, readonly) NSString * myPropertyObjectiveCName NS_SWIFT_NAME(myPropertySwiftName); + // + // - (void)myMethodObjectiveCName NS_SWIFT_NAME(myMethodSwiftName()); + // - (void)myMethodWithArgument:(NSString *)argument NS_SWIFT_NAME(myMethod(argument:)); + // + // @end + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCClassSwiftName") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCClassSwiftName/myPropertySwiftName") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCClassSwiftName/myMethodSwiftName()") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCClassSwiftName/myMethod(argument:)") + + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MyObjectiveCClassObjectiveCName", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyObjectiveCClassSwiftName" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MyObjectiveCClassObjectiveCName/myPropertyObjectiveCName", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyObjectiveCClassSwiftName/myPropertySwiftName" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MyObjectiveCClassObjectiveCName/myMethodObjectiveCName", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyObjectiveCClassSwiftName/myMethodSwiftName()" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MyObjectiveCClassObjectiveCName/myMethodWithArgument:", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyObjectiveCClassSwiftName/myMethod(argument:)" + ) + + // typedef NS_ENUM(NSInteger, MyObjectiveCEnum) { + // MyObjectiveCEnumFirst, + // MyObjectiveCEnumSecond NS_SWIFT_NAME(secondCaseSwiftName) + // }; + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCEnum") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCEnum/first") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCEnum/secondCaseSwiftName") + + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MyObjectiveCEnum/MyObjectiveCEnumFirst", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyObjectiveCEnum/first" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MyObjectiveCEnum/MyObjectiveCEnumSecond", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyObjectiveCEnum/secondCaseSwiftName" + ) + + // typedef NS_ENUM(NSInteger, MyObjectiveCEnumObjectiveCName) { + // MyObjectiveCEnumObjectiveCNameFirst, + // MyObjectiveCEnumObjectiveCNameSecond NS_SWIFT_NAME(secondCaseSwiftName) + // } NS_SWIFT_NAME(MyObjectiveCEnumSwiftName); + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCEnumSwiftName") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCEnumSwiftName/first") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCEnumSwiftName/secondCaseSwiftName") + + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MyObjectiveCEnumObjectiveCName", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyObjectiveCEnumSwiftName" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MyObjectiveCEnumObjectiveCName/MyObjectiveCEnumObjectiveCNameFirst", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyObjectiveCEnumSwiftName/first" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MyObjectiveCEnumObjectiveCName/MyObjectiveCEnumObjectiveCNameSecond", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyObjectiveCEnumSwiftName/secondCaseSwiftName" + ) + + // typedef NS_OPTIONS(NSInteger, MyObjectiveCOption) { + // MyObjectiveCOptionNone = 0, + // MyObjectiveCOptionFirst = 1 << 0, + // MyObjectiveCOptionSecond NS_SWIFT_NAME(secondCaseSwiftName) = 1 << 1 + // }; + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCOption") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCOption/first") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCOption/secondCaseSwiftName") + + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyObjectiveCOption/MyObjectiveCOptionNone") + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MyObjectiveCOption/MyObjectiveCOptionFirst", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyObjectiveCOption/first" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MyObjectiveCOption/MyObjectiveCOptionSecond", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyObjectiveCOption/secondCaseSwiftName" + ) + + // typedef NSInteger MyTypedObjectiveCEnum NS_TYPED_ENUM; + // + // MyTypedObjectiveCEnum const MyTypedObjectiveCEnumFirst; + // MyTypedObjectiveCEnum const MyTypedObjectiveCEnumSecond; + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyTypedObjectiveCEnum") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyTypedObjectiveCEnum/first") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyTypedObjectiveCEnum/second") + + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MyTypedObjectiveCEnumFirst", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyTypedObjectiveCEnum/first" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MyTypedObjectiveCEnumSecond", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyTypedObjectiveCEnum/second" + ) + + // typedef NSInteger MyTypedObjectiveCExtensibleEnum NS_TYPED_EXTENSIBLE_ENUM; + // + // MyTypedObjectiveCExtensibleEnum const MyTypedObjectiveCExtensibleEnumFirst; + // MyTypedObjectiveCExtensibleEnum const MyTypedObjectiveCExtensibleEnumSecond; + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyTypedObjectiveCExtensibleEnum") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyTypedObjectiveCExtensibleEnum/first") + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework/MyTypedObjectiveCExtensibleEnum/second") + + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MyTypedObjectiveCExtensibleEnumFirst", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyTypedObjectiveCExtensibleEnum/first" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework/MyTypedObjectiveCExtensibleEnumSecond", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyTypedObjectiveCExtensibleEnum/second" + ) + } + + func testAmbiguousPaths() throws { + let linkResolvers = try makeLinkResolversForTestBundle(named: "MixedLanguageFrameworkWithLanguageRefinements") + + // public enum CollisionsWithDifferentKinds { + // case something + // public var something: String { "" } + // public typealias Something = Int + // } + try linkResolvers.assertFailsToResolve( + authoredLink: "/MixedFramework/CollisionsWithDifferentKinds/something", + errorMessage: "'something' is ambiguous at '/MixedFramework/CollisionsWithDifferentKinds'", + solutions: [ + .init(summary: "Insert 'enum.case' for\n'case something'", replacement: ("-enum.case", 54, 54)), + .init(summary: "Insert 'property' for\n'var something: String { get }'", replacement: ("-property", 54, 54)), + ] + ) + + try linkResolvers.assertFailsToResolve( + authoredLink: "/MixedFramework/CollisionsWithDifferentKinds/something-class", + errorMessage: "'class' isn't a disambiguation for 'something' at '/MixedFramework/CollisionsWithDifferentKinds'", + solutions: [ + .init(summary: "Replace 'class' with 'enum.case' for\n'case something'", replacement: ("-enum.case", 54, 60)), + .init(summary: "Replace 'class' with 'property' for\n'var something: String { get }'", replacement: ("-property", 54, 60)), + ] + ) + + // public final class CollisionsWithEscapedKeywords { + // public subscript() -> Int { 0 } + // public func `subscript`() { } + // public static func `subscript`() { } + // + // public init() { } + // public func `init`() { } + // public static func `init`() { } + // } + + try linkResolvers.assertFailsToResolve( + authoredLink: "/MixedFramework/CollisionsWithEscapedKeywords/init()", + errorMessage: "'init()' is ambiguous at '/MixedFramework/CollisionsWithEscapedKeywords'", + solutions: [ + .init(summary: "Insert 'method' for\n'func `init`()'", replacement: ("-method", 52, 52)), + .init(summary: "Insert 'init' for\n'init()'", replacement: ("-init", 52, 52)), + .init(summary: "Insert 'type.method' for\n'static func `init`()'", replacement: ("-type.method", 52, 52)), + ] + ) + try linkResolvers.assertFailsToResolve( + authoredLink: "/MixedFramework/CollisionsWithEscapedKeywords/init()-abc123", + errorMessage: "'abc123' isn't a disambiguation for 'init()' at '/MixedFramework/CollisionsWithEscapedKeywords'", + solutions: [ + .init(summary: "Replace 'abc123' with 'method' for\n'func `init`()'", replacement: ("-method", 52, 59)), + .init(summary: "Replace 'abc123' with 'init' for\n'init()'", replacement: ("-init", 52, 59)), + .init(summary: "Replace 'abc123' with 'type.method' for\n'static func `init`()'", replacement: ("-type.method", 52, 59)), + ] + ) + // Providing disambiguation will narrow down the suggestions. Note that `()` is missing in the last path component + try linkResolvers.assertFailsToResolve( + authoredLink: "/MixedFramework/CollisionsWithEscapedKeywords/init-method", + errorMessage: "'init-method' doesn't exist at '/MixedFramework/CollisionsWithEscapedKeywords'", + solutions: [ + .init(summary: "Replace 'init' with 'init()'", replacement: ("init()", 46, 50)), // The disambiguation is not replaced so the suggested link is unambiguous + ] + ) + try linkResolvers.assertFailsToResolve( + authoredLink: "/MixedFramework/CollisionsWithEscapedKeywords/init-init", + errorMessage: "'init-init' doesn't exist at '/MixedFramework/CollisionsWithEscapedKeywords'", + solutions: [ + .init(summary: "Replace 'init' with 'init()'", replacement: ("init()", 46, 50)), // The disambiguation is not replaced so the suggested link is unambiguous + ] + ) + try linkResolvers.assertFailsToResolve( + authoredLink: "/MixedFramework/CollisionsWithEscapedKeywords/init-type.method", + errorMessage: "'init-type.method' doesn't exist at '/MixedFramework/CollisionsWithEscapedKeywords'", + solutions: [ + .init(summary: "Replace 'init' with 'init()'", replacement: ("init()", 46, 50)), // The disambiguation is not replaced so the suggested link is unambiguous + ] + ) + + try linkResolvers.assertFailsToResolve( + authoredLink: "/MixedFramework/CollisionsWithEscapedKeywords/subscript()", + errorMessage: "'subscript()' is ambiguous at '/MixedFramework/CollisionsWithEscapedKeywords'", + solutions: [ + .init(summary: "Insert 'method' for\n'func `subscript`()'", replacement: ("-method", 57, 57)), + .init(summary: "Insert 'type.method' for\n'static func `subscript`()'", replacement: ("-type.method", 57, 57)), + .init(summary: "Insert 'subscript' for\n'subscript() -> Int { get }'", replacement: ("-subscript", 57, 57)), + ] + ) + + // public enum CollisionsWithDifferentFunctionArguments { + // public func something(argument: Int) -> Int { 0 } + // public func something(argument: String) -> Int { 0 } + // } + try linkResolvers.assertFailsToResolve( + authoredLink: "/MixedFramework/CollisionsWithDifferentFunctionArguments/something(argument:)", + errorMessage: "'something(argument:)' is ambiguous at '/MixedFramework/CollisionsWithDifferentFunctionArguments'", + solutions: [ + .init(summary: "Insert '1cyvp' for\n'func something(argument: Int) -> Int'", replacement: ("-1cyvp", 77, 77)), + .init(summary: "Insert '2vke2' for\n'func something(argument: String) -> Int'", replacement: ("-2vke2", 77, 77)), + ] + ) + try linkResolvers.assertFailsToResolve( + authoredLink: "/documentation/MixedFramework/CollisionsWithDifferentFunctionArguments/something(argument:)", + errorMessage: "'something(argument:)' is ambiguous at '/MixedFramework/CollisionsWithDifferentFunctionArguments'", + solutions: [ + .init(summary: "Insert '1cyvp' for\n'func something(argument: Int) -> Int'", replacement: ("-1cyvp", 91, 91)), + .init(summary: "Insert '2vke2' for\n'func something(argument: String) -> Int'", replacement: ("-2vke2", 91, 91)), + ] + ) + try linkResolvers.assertFailsToResolve( + authoredLink: "/MixedFramework/CollisionsWithDifferentFunctionArguments/something(argument:)-abc123", + errorMessage: "'abc123' isn't a disambiguation for 'something(argument:)' at '/MixedFramework/CollisionsWithDifferentFunctionArguments'", + solutions: [ + .init(summary: "Replace 'abc123' with '1cyvp' for\n'func something(argument: Int) -> Int'", replacement: ("-1cyvp", 77, 84)), + .init(summary: "Replace 'abc123' with '2vke2' for\n'func something(argument: String) -> Int'", replacement: ("-2vke2", 77, 84)), + ] + ) + // Providing disambiguation will narrow down the suggestions. Note that `argument` label is missing in the last path component + try linkResolvers.assertFailsToResolve( + authoredLink: "/MixedFramework/CollisionsWithDifferentFunctionArguments/something(_:)-1cyvp", + errorMessage: "'something(_:)-1cyvp' doesn't exist at '/MixedFramework/CollisionsWithDifferentFunctionArguments'", + solutions: [ + .init(summary: "Replace 'something(_:)' with 'something(argument:)'", replacement: ("something(argument:)", 57, 70)), // The disambiguation is not replaced so the suggested link is unambiguous + ] + ) + try linkResolvers.assertFailsToResolve( + authoredLink: "/MixedFramework/CollisionsWithDifferentFunctionArguments/something(_:)-2vke2", + errorMessage: "'something(_:)-2vke2' doesn't exist at '/MixedFramework/CollisionsWithDifferentFunctionArguments'", + solutions: [ + .init(summary: "Replace 'something(_:)' with 'something(argument:)'", replacement: ("something(argument:)", 57, 70)), // The disambiguation is not replaced so the suggested link is unambiguous + ] + ) + try linkResolvers.assertFailsToResolve( + authoredLink: "/MixedFramework/CollisionsWithDifferentFunctionArguments/something(argument:)-method", + errorMessage: "'something(argument:)-method' is ambiguous at '/MixedFramework/CollisionsWithDifferentFunctionArguments'", + solutions: [ + .init(summary: "Replace 'method' with '1cyvp' for\n'func something(argument: Int) -> Int'", replacement: ("-1cyvp", 77, 84)), + .init(summary: "Replace 'method' with '2vke2' for\n'func something(argument: String) -> Int'", replacement: ("-2vke2", 77, 84)), + ] + ) + try linkResolvers.assertFailsToResolve( + authoredLink: "/documentation/MixedFramework/CollisionsWithDifferentFunctionArguments/something(argument:)-method", + errorMessage: "'something(argument:)-method' is ambiguous at '/MixedFramework/CollisionsWithDifferentFunctionArguments'", + solutions: [ + .init(summary: "Replace 'method' with '1cyvp' for\n'func something(argument: Int) -> Int'", replacement: ("-1cyvp", 91, 98)), + .init(summary: "Replace 'method' with '2vke2' for\n'func something(argument: String) -> Int'", replacement: ("-2vke2", 91, 98)), + ] + ) + + // public enum CollisionsWithDifferentSubscriptArguments { + // public subscript(something: Int) -> Int { 0 } + // public subscript(somethingElse: String) -> Int { 0 } + // } + try linkResolvers.assertFailsToResolve( + authoredLink: "/MixedFramework/CollisionsWithDifferentSubscriptArguments/subscript(_:)", + errorMessage: "'subscript(_:)' is ambiguous at '/MixedFramework/CollisionsWithDifferentSubscriptArguments'", + solutions: [ + .init(summary: "Insert '4fd0l' for\n'subscript(something: Int) -> Int { get }'", replacement: ("-4fd0l", 71, 71)), + .init(summary: "Insert '757cj' for\n'subscript(somethingElse: String) -> Int { get }'", replacement: ("-757cj", 71, 71)), + ] + ) + try linkResolvers.assertFailsToResolve( + authoredLink: "/MixedFramework/CollisionsWithDifferentSubscriptArguments/subscript(_:)-subscript", + errorMessage: "'subscript(_:)-subscript' is ambiguous at '/MixedFramework/CollisionsWithDifferentSubscriptArguments'", + solutions: [ + .init(summary: "Replace 'subscript' with '4fd0l' for\n'subscript(something: Int) -> Int { get }'", replacement: ("-4fd0l", 71, 81)), + .init(summary: "Replace 'subscript' with '757cj' for\n'subscript(somethingElse: String) -> Int { get }'", replacement: ("-757cj", 71, 81)), + ] + ) + } + + func testRedundantDisambiguations() throws { + let linkResolvers = try makeLinkResolversForTestBundle(named: "MixedLanguageFrameworkWithLanguageRefinements") + + try linkResolvers.assertSuccessfullyResolves(authoredLink: "/MixedFramework") + + // @objc public enum MyEnum: Int { + // case firstCase + // case secondCase + // public func myEnumFunction() { } + // public typealias MyEnumTypeAlias = Int + // public var myEnumProperty: MyEnumTypeAlias { 0 } + // } + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MyEnum-enum-1m96o", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyEnum" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MyEnum-enum-1m96o/firstCase-enum.case-5ocr4", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyEnum/firstCase" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MyEnum-enum-1m96o/secondCase-enum.case-ihyt", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyEnum/secondCase" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MyEnum-enum-1m96o/myEnumFunction()-method-2pa9q", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyEnum/myEnumFunction()" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MyEnum-enum-1m96o/MyEnumTypeAlias-typealias-5ejt4", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyEnum/MyEnumTypeAlias" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MyEnum-enum-1m96o/myEnumProperty-property-6cz2q", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyEnum/myEnumProperty" + ) + + // public struct MyStruct { + // public func myStructFunction() { } + // public typealias MyStructTypeAlias = Int + // public var myStructProperty: MyStructTypeAlias { 0 } + // public static var myStructTypeProperty: MyStructTypeAlias { 0 } + // } + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MyStruct-struct-23xcd", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyStruct" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MyStruct-struct-23xcd/myStructFunction()-method-9p92r", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyStruct/myStructFunction()" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MyStruct-struct-23xcd/MyStructTypeAlias-typealias-630hf", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyStruct/MyStructTypeAlias" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MyStruct-struct-23xcd/myStructProperty-property-5ywbx", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyStruct/myStructProperty" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MyStruct-struct-23xcd/myStructTypeProperty-type.property-8ti6m", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MyStruct/myStructTypeProperty" + ) + + // public protocol MySwiftProtocol { + // func myProtocolMethod() + // associatedtype MyProtocolAssociatedType + // typealias MyProtocolTypeAlias = MyStruct + // var myProtocolProperty: MyProtocolAssociatedType { get } + // static var myProtocolTypeProperty: MyProtocolAssociatedType { get } + // } + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MySwiftProtocol-protocol-xmee", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MySwiftProtocol" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MySwiftProtocol-protocol-xmee/myProtocolMethod()-method-6srz6", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MySwiftProtocol/myProtocolMethod()" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MySwiftProtocol-protocol-xmee/MyProtocolAssociatedType-associatedtype-33siz", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MySwiftProtocol/MyProtocolAssociatedType" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MySwiftProtocol-protocol-xmee/MyProtocolTypeAlias-typealias-9rpv6", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MySwiftProtocol/MyProtocolTypeAlias" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MySwiftProtocol-protocol-xmee/myProtocolProperty-property-qer2", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MySwiftProtocol/myProtocolProperty" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/MySwiftProtocol-protocol-xmee/myProtocolTypeProperty-type.property-8h7hm", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/MySwiftProtocol/myProtocolTypeProperty" + ) + + // public func myTopLevelFunction() { } + // public var myTopLevelVariable = true + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/myTopLevelFunction()-func-55lhl", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/myTopLevelFunction()" + ) + try linkResolvers.assertSuccessfullyResolves( + authoredLink: "/MixedFramework-module-9r7pl/myTopLevelVariable-var-520ez", + to: "doc://org.swift.MixedFramework/documentation/MixedFramework/myTopLevelVariable" + ) + } + + // MARK: Test helpers + + struct LinkResolvers { + let localResolver: PathHierarchyBasedLinkResolver + let externalResolver: ExternalPathHierarchyResolver + let context: DocumentationContext + + func assertResults(authoredLink: String, verification: (TopicReferenceResolutionResult, String) throws -> Void) throws { + let unresolvedReference = try XCTUnwrap(ValidatedURL(parsingAuthoredLink: authoredLink).map(UnresolvedTopicReference.init(topicURL:))) + let rootModule = try XCTUnwrap(context.soleRootModuleReference) + + let linkResolver = LinkResolver() + linkResolver.localResolver = localResolver + let localResult = linkResolver.resolve(unresolvedReference, in: rootModule, fromSymbolLink: true, context: context) + let externalResult = externalResolver.resolve(unresolvedReference, fromSymbolLink: true) + + try verification(localResult, "local") + try verification(externalResult, "external") + } + + func assertSuccessfullyResolves( + authoredLink: String, + to absoluteReferenceString: String? = nil, + file: StaticString = #file, + line: UInt = #line + ) throws { + let expectedAbsoluteReferenceString = absoluteReferenceString ?? { + context.soleRootModuleReference!.url + .deletingLastPathComponent() // Remove the module name + .appendingPathComponent(authoredLink.trimmingCharacters(in: ["/"])) // Append the authored link, without leading slashes + .absoluteString + }() + + try assertResults(authoredLink: authoredLink) { result, label in + switch result { + case .success(let resolved): + XCTAssertEqual(resolved.absoluteString, expectedAbsoluteReferenceString, label, 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) + } + } + } + + func assertFailsToResolve( + authoredLink: String, + errorMessage: String, + solutions: [Solution], + file: StaticString = #file, + line: UInt = #line + ) throws { + try assertResults(authoredLink: authoredLink) { result, label in + switch result { + case .success: + XCTFail("Unexpectedly resolved link with wrong module name for \(label)", file: file, line: line) + case .failure(_, let errorInfo): + XCTAssertEqual(errorInfo.message, errorMessage, label, file: file, line: line) + XCTAssertEqual(errorInfo.solutions.count, solutions.count, "Unexpected number of solutions for \(label) link", file: file, line: line) + for (actualSolution, expectedSolution) in zip(errorInfo.solutions, solutions) { + XCTAssertEqual(actualSolution.summary, expectedSolution.summary, label, file: file, line: line) + let replacement = try XCTUnwrap(actualSolution.replacements.first) + + XCTAssertEqual(replacement.replacement, expectedSolution.replacement.0, label, file: file, line: line) + XCTAssertEqual(replacement.range.lowerBound.column, expectedSolution.replacement.1, label, file: file, line: line) + XCTAssertEqual(replacement.range.upperBound.column, expectedSolution.replacement.2, label, file: file, line: line) + } + } + } + } + + struct Solution { + var summary: String + var replacement: (String, Int, Int) + } + } + + private func makeLinkResolversForTestBundle(named testBundleName: String) throws -> LinkResolvers { + let (bundle, context) = try testBundleAndContext(named: testBundleName) + let localResolver = try XCTUnwrap(context.linkResolver.localResolver) + + let resolverInfo = try localResolver.prepareForSerialization(bundleID: bundle.identifier) + let resolverData = try JSONEncoder().encode(resolverInfo) + let roundtripResolverInfo = try JSONDecoder().decode(SerializableLinkResolutionInformation.self, from: resolverData) + + var entitySummaries = [LinkDestinationSummary]() + let converter = DocumentationNodeConverter(bundle: bundle, context: context) + for reference in context.knownPages { + let node = try context.entity(with: reference) + let renderNode = try converter.convert(node, at: nil) + entitySummaries.append(contentsOf: node.externallyLinkableElementSummaries(context: context, renderNode: renderNode, includeTaskGroups: false)) + } + + let externalResolver = ExternalPathHierarchyResolver( + linkInformation: roundtripResolverInfo, + entityInformation: entitySummaries + ) + + return LinkResolvers(localResolver: localResolver, externalResolver: externalResolver, context: context) + } +} diff --git a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift index 240ab3d56e..48e99b2137 100644 --- a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift @@ -18,7 +18,7 @@ class PathHierarchyTests: XCTestCase { func testFindingUnambiguousAbsolutePaths() throws { let (_, context) = try testBundleAndContext(named: "MixedLanguageFrameworkWithLanguageRefinements") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) try assertFindsPath("/MixedFramework", in: tree, asSymbolID: "MixedFramework") @@ -281,19 +281,25 @@ class PathHierarchyTests: XCTestCase { } func testAmbiguousPaths() throws { + let originalFeatureFlagsState = FeatureFlags.current + FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled = true + defer { + FeatureFlags.current = originalFeatureFlagsState + } + let (_, context) = try testBundleAndContext(named: "MixedLanguageFrameworkWithLanguageRefinements") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) // Symbol name not found. Suggestions only include module names (search is not relative to a known page) try assertPathRaisesErrorMessage("/MixFramework", in: tree, context: context, expectedErrorMessage: """ - Can't resolve 'MixFramework' + No module named 'MixFramework' """) { error in XCTAssertEqual(error.solutions, [ .init(summary: "Replace 'MixFramework' with 'MixedFramework'", replacements: [("MixedFramework", 1, 13)]), ]) } try assertPathRaisesErrorMessage("/documentation/MixFramework", in: tree, context: context, expectedErrorMessage: """ - Can't resolve 'MixFramework' + No module named 'MixFramework' """) { error in XCTAssertEqual(error.solutions, [ .init(summary: "Replace 'MixFramework' with 'MixedFramework'", replacements: [("MixedFramework", 15, 27)]), @@ -568,7 +574,7 @@ class PathHierarchyTests: XCTestCase { func testRedundantKindDisambiguation() throws { let (_, context) = try testBundleAndContext(named: "MixedLanguageFrameworkWithLanguageRefinements") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) try assertFindsPath("/MixedFramework-module", in: tree, asSymbolID: "MixedFramework") @@ -620,7 +626,7 @@ class PathHierarchyTests: XCTestCase { func testBothRedundantDisambiguations() throws { let (_, context) = try testBundleAndContext(named: "MixedLanguageFrameworkWithLanguageRefinements") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) try assertFindsPath("/MixedFramework-module-9r7pl", in: tree, asSymbolID: "MixedFramework") @@ -684,7 +690,7 @@ class PathHierarchyTests: XCTestCase { // @_exported import Inner // public typealias Something = Inner.Something let (_, context) = try testBundleAndContext(named: "DefaultImplementationsWithExportedImport") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) // The @_export imported protocol can be found try assertFindsPath("/DefaultImplementationsWithExportedImport/Something-protocol", in: tree, asSymbolID: "s:5Inner9SomethingP") @@ -706,7 +712,7 @@ class PathHierarchyTests: XCTestCase { func testDisambiguatedPaths() throws { let (_, context) = try testBundleAndContext(named: "MixedLanguageFrameworkWithLanguageRefinements") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) let paths = tree.caseInsensitiveDisambiguatedPaths() // @objc public enum MyEnum: Int { @@ -827,7 +833,7 @@ class PathHierarchyTests: XCTestCase { func testFindingRelativePaths() throws { let (_, context) = try testBundleAndContext(named: "MixedLanguageFrameworkWithLanguageRefinements") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) let moduleID = try tree.find(path: "/MixedFramework", onlyFindSymbols: true) @@ -980,7 +986,7 @@ class PathHierarchyTests: XCTestCase { func testPathWithDocumentationPrefix() throws { let (_, context) = try testBundleAndContext(named: "MixedLanguageFrameworkWithLanguageRefinements") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) let moduleID = try tree.find(path: "/MixedFramework", onlyFindSymbols: true) @@ -995,7 +1001,7 @@ class PathHierarchyTests: XCTestCase { func testTestBundle() throws { let (bundle, context) = try testBundleAndContext(named: "TestBundle") - let linkResolver = try XCTUnwrap(context.hierarchyBasedLinkResolver) + let linkResolver = try XCTUnwrap(context.linkResolver.localResolver) let tree = try XCTUnwrap(linkResolver.pathHierarchy) // Test finding the parent via the `fromTopicReference` integration shim. @@ -1053,7 +1059,7 @@ class PathHierarchyTests: XCTestCase { func testMixedLanguageFramework() throws { let (_, context) = try testBundleAndContext(named: "MixedLanguageFramework") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) try assertFindsPath("MixedLanguageFramework/Bar/myStringFunction(_:)", in: tree, asSymbolID: "c:objc(cs)Bar(cm)myStringFunction:error:") try assertFindsPath("MixedLanguageFramework/Bar/myStringFunction:error:", in: tree, asSymbolID: "c:objc(cs)Bar(cm)myStringFunction:error:") @@ -1113,7 +1119,7 @@ class PathHierarchyTests: XCTestCase { This article has the same path as a symbol """.write(to: url.appendingPathComponent("Bar.md"), atomically: true, encoding: .utf8) } - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) // The added article above has the same path as an existing symbol in the this module. let symbolNode = try tree.findNode(path: "/MixedLanguageFramework/Bar", onlyFindSymbols: true) @@ -1136,7 +1142,7 @@ class PathHierarchyTests: XCTestCase { """.write(to: url.appendingPathComponent("ArticleWithHeading.md"), atomically: true, encoding: .utf8) } - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) let articleNode = try tree.findNode(path: "/MixedLanguageFramework/ArticleWithHeading", onlyFindSymbols: false) let linkNode = try tree.find(path: "TestTargetHeading", parent: articleNode.identifier, onlyFindSymbols: false) @@ -1147,7 +1153,7 @@ class PathHierarchyTests: XCTestCase { func testOverloadedSymbols() throws { let (_, context) = try testBundleAndContext(named: "OverloadedSymbols") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) let paths = tree.caseInsensitiveDisambiguatedPaths() @@ -1180,7 +1186,7 @@ class PathHierarchyTests: XCTestCase { func testSymbolsWithSameNameAsModule() throws { let (_, context) = try testBundleAndContext(named: "SymbolsWithSameNameAsModule") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) // /* in a module named "Something "*/ // public struct Something { @@ -1241,7 +1247,7 @@ class PathHierarchyTests: XCTestCase { // func something() {} // } let (_, context) = try testBundleAndContext(named: "ShadowExtendedModuleWithLocalSymbol") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) try assertPathCollision("Outer/Inner", in: tree, collisions: [ ("s:m:s:e:s:5Inner0A5ClassC5OuterE9somethingyyF", "module.extension"), @@ -1270,7 +1276,7 @@ class PathHierarchyTests: XCTestCase { func testSnippets() throws { let (_, context) = try testBundleAndContext(named: "Snippets") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) try assertFindsPath("/Snippets/Snippets/MySnippet", in: tree, asSymbolID: "$snippet__Test.Snippets.MySnippet") @@ -1295,7 +1301,7 @@ class PathHierarchyTests: XCTestCase { func testInheritedOperators() throws { let (_, context) = try testBundleAndContext(named: "InheritedOperators") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) // public struct MyNumber: SignedNumeric, Comparable, Equatable, Hashable { // ... stub minimal conformance @@ -1355,7 +1361,7 @@ class PathHierarchyTests: XCTestCase { func testSameNameForSymbolAndContainer() throws { let (_, context) = try testBundleAndContext(named: "BundleWithSameNameForSymbolAndContainer") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) // public struct Something { // public struct Something { @@ -1399,7 +1405,7 @@ class PathHierarchyTests: XCTestCase { // Also change the display name so that the article container has the same name as the module. try InfoPlist(displayName: "Something", identifier: "com.example.Something").write(inside: url) } - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) do { // Links to non-symbols can use only the file name, without specifying the module or catalog name. @@ -1429,7 +1435,7 @@ class PathHierarchyTests: XCTestCase { do { let (_, _, context) = try loadBundle(from: bundleURL) - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) try assertFindsPath("/MyKit/MyClass/myFunction()", in: tree, asSymbolID: "s:5MyKit0A5ClassC10myFunctionyyF") try assertPathNotFound("/MyKit/MyClass-swift.class/myFunction()", in: tree) @@ -1448,7 +1454,7 @@ class PathHierarchyTests: XCTestCase { "s:5MyKit0A5ClassC10myFunctionyyF": ["MyClass-swift.class", "myFunction()"] ] } - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) try assertFindsPath("/MyKit/MyClass-swift.class/myFunction()", in: tree, asSymbolID: "s:5MyKit0A5ClassC10myFunctionyyF") try assertPathNotFound("/MyKit/MyClass", in: tree) @@ -1467,7 +1473,7 @@ class PathHierarchyTests: XCTestCase { "s:5MyKit0A5ClassC10myFunctionyyF": ["MyClass-swift.class-hash", "myFunction()"] ] } - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) try assertFindsPath("/MyKit/MyClass-swift.class-hash/myFunction()", in: tree, asSymbolID: "s:5MyKit0A5ClassC10myFunctionyyF") try assertPathNotFound("/MyKit/MyClass", in: tree) @@ -1510,7 +1516,7 @@ class PathHierarchyTests: XCTestCase { let (_, _, context) = try loadBundle(from: bundleURL) XCTAssert(context.problems.isEmpty, "Unexpected problems: \(context.problems.map { DiagnosticConsoleWriter.formattedDescription(for: $0) })") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) let baseKitID = try tree.find(path: "/BaseKit", onlyFindSymbols: true) @@ -1560,7 +1566,7 @@ class PathHierarchyTests: XCTestCase { let bundleURL = try exampleDocumentation.write(inside: tempURL) let (_, _, context) = try loadBundle(from: bundleURL) - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) try assertPathNotFound("/Module/A", in: tree) try assertPathNotFound("/Module/A/B", in: tree) @@ -1583,7 +1589,7 @@ class PathHierarchyTests: XCTestCase { func testMultiPlatformModuleWithExtension() throws { let (_, context) = try testBundleAndContext(named: "MultiPlatformModuleWithExtension") - let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy) try assertFindsPath("/MainModule/TopLevelProtocol/extensionMember(_:)", in: tree, asSymbolID: "extensionMember1") try assertFindsPath("/MainModule/TopLevelProtocol/InnerStruct/extensionMember(_:)", in: tree, asSymbolID: "extensionMember2") @@ -1675,7 +1681,7 @@ class PathHierarchyTests: XCTestCase { private func assertPathRaisesErrorMessage(_ path: String, in tree: PathHierarchy, context: DocumentationContext, expectedErrorMessage: String, file: StaticString = #file, line: UInt = #line, _ additionalAssertion: (TopicReferenceResolutionErrorInfo) -> Void = { _ in }) throws { XCTAssertThrowsError(try tree.findSymbol(path: path), "Finding path \(path) didn't raise an error.",file: file,line: line) { untypedError in let error = untypedError as! PathHierarchy.Error - let referenceError = error.asTopicReferenceResolutionErrorInfo(context: context, originalReference: path) + let referenceError = error.asTopicReferenceResolutionErrorInfo(originalReference: path) { context.linkResolver.localResolver.fullName(of: $0, in: context) } XCTAssertEqual(referenceError.message, expectedErrorMessage, file: file, line: line) additionalAssertion(referenceError) } diff --git a/Tests/SwiftDocCTests/Infrastructure/SymbolDisambiguationTests.swift b/Tests/SwiftDocCTests/Infrastructure/SymbolDisambiguationTests.swift index 8c592ff389..3fcc147c99 100644 --- a/Tests/SwiftDocCTests/Infrastructure/SymbolDisambiguationTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/SymbolDisambiguationTests.swift @@ -194,7 +194,7 @@ class SymbolDisambiguationTests: XCTestCase { var loader = SymbolGraphLoader(bundle: bundle, dataProvider: context.dataProvider) try loader.loadAll() - let references = context.hierarchyBasedLinkResolver.referencesForSymbols(in: loader.unifiedGraphs, bundle: bundle, context: context).mapValues(\.path) + let references = context.linkResolver.localResolver.referencesForSymbols(in: loader.unifiedGraphs, bundle: bundle, context: context).mapValues(\.path) XCTAssertEqual(Set(references.keys), [ SymbolGraph.Symbol.Identifier(precise: "c:@CM@TestFramework@objc(cs)MixedLanguageClassConformingToProtocol(im)mixedLanguageMethod", interfaceLanguage: "swift"), .init(precise: "c:@E@Foo", interfaceLanguage: "swift"), @@ -352,6 +352,6 @@ class SymbolDisambiguationTests: XCTestCase { let context = try DocumentationContext(dataProvider: provider) - return context.hierarchyBasedLinkResolver.referencesForSymbols(in: ["SymbolDisambiguationTests": unified], bundle: bundle, context: context) + return context.linkResolver.localResolver.referencesForSymbols(in: ["SymbolDisambiguationTests": unified], bundle: bundle, context: context) } } diff --git a/Tests/SwiftDocCTests/LinkTargets/LinkDestinationSummaryTests.swift b/Tests/SwiftDocCTests/LinkTargets/LinkDestinationSummaryTests.swift index d5c759bf83..d6e877c56c 100644 --- a/Tests/SwiftDocCTests/LinkTargets/LinkDestinationSummaryTests.swift +++ b/Tests/SwiftDocCTests/LinkTargets/LinkDestinationSummaryTests.swift @@ -260,7 +260,17 @@ class ExternalLinkableTests: XCTestCase { XCTAssertEqual(summary.availableLanguages, [.swift]) XCTAssertEqual(summary.platforms, renderNode.metadata.platforms) XCTAssertEqual(summary.usr, "s:5MyKit0A5ClassC10myFunctionyyF") - XCTAssertEqual(summary.declarationFragments, nil) // This symbol doesn't have a `subHeading` in the symbol graph + XCTAssertEqual(summary.declarationFragments, [ + .init(text: "func", kind: .keyword, identifier: nil), + .init(text: " ", kind: .text, identifier: nil), + .init(text: "myFunction", kind: .identifier, identifier: nil), + .init(text: "(", kind: .text, identifier: nil), + .init(text: "for", kind: .externalParam, identifier: nil), + .init(text: " ", kind: .text, identifier: nil), + .init(text: "name", kind: .internalParam, identifier: nil), + .init(text: "...", kind: .text, identifier: nil), + .init(text: ")", kind: .text, identifier: nil) + ]) XCTAssertNil(summary.topicImages) XCTAssertNil(summary.references) @@ -290,13 +300,14 @@ class ExternalLinkableTests: XCTestCase { .init(text: " ", kind: .text, identifier: nil), .init(text: "globalFunction", kind: .identifier, identifier: nil), .init(text: "(", kind: .text, identifier: nil), + .init(text: "_", kind: .identifier, identifier: nil), + .init(text: ": ", kind: .text, identifier: nil), .init(text: "Data", kind: .typeIdentifier, identifier: nil, preciseIdentifier: "s:10Foundation4DataV"), .init(text: ", ", kind: .text, identifier: nil), .init(text: "considering", kind: .identifier, identifier: nil), .init(text: ": ", kind: .text, identifier: nil), .init(text: "Int", kind: .typeIdentifier, identifier: nil, preciseIdentifier: "s:Si"), - .init(text: ")", kind: .text, identifier: nil), - .init(text: "\n", kind: .text, identifier: nil), + .init(text: ")", kind: .text, identifier: nil) ]) XCTAssertNil(summary.topicImages) XCTAssertNil(summary.references) @@ -341,7 +352,17 @@ class ExternalLinkableTests: XCTestCase { XCTAssertEqual(summary.availableLanguages, [.swift]) XCTAssertEqual(summary.platforms, renderNode.metadata.platforms) XCTAssertEqual(summary.usr, "s:5MyKit0A5ClassC10myFunctionyyF") - XCTAssertEqual(summary.declarationFragments, nil) // This symbol doesn't have a `subHeading` in the symbol graph + XCTAssertEqual(summary.declarationFragments, [ + .init(text: "func", kind: .keyword, identifier: nil), + .init(text: " ", kind: .text, identifier: nil), + .init(text: "myFunction", kind: .identifier, identifier: nil), + .init(text: "(", kind: .text, identifier: nil), + .init(text: "for", kind: .externalParam, identifier: nil), + .init(text: " ", kind: .text, identifier: nil), + .init(text: "name", kind: .internalParam, identifier: nil), + .init(text: "...", kind: .text, identifier: nil), + .init(text: ")", kind: .text, identifier: nil) + ]) XCTAssertEqual(summary.topicImages, [ TopicImage( @@ -511,11 +532,15 @@ class ExternalLinkableTests: XCTestCase { .init(text: " ", kind: .text, identifier: nil), .init(text: "myStringFunction", kind: .identifier, identifier: nil), .init(text: "(", kind: .text, identifier: nil), - .init(text: "String", kind: .typeIdentifier, identifier: nil, preciseIdentifier: Optional("s:SS")), + .init(text: "_", kind: .externalParam, identifier: nil), + .init(text: " ", kind: .text, identifier: nil), + .init(text: "string", kind: .internalParam, identifier: nil), + .init(text: ": ", kind: .text, identifier: nil), + .init(text: "String", kind: .typeIdentifier, identifier: nil, preciseIdentifier: "s:SS"), .init(text: ") ", kind: .text, identifier: nil), .init(text: "throws", kind: .keyword, identifier: nil), .init(text: " -> ", kind: .text, identifier: nil), - .init(text: "String", kind: .typeIdentifier, identifier: nil, preciseIdentifier: Optional("s:SS")) + .init(text: "String", kind: .typeIdentifier, identifier: nil, preciseIdentifier: "s:SS") ]) XCTAssertNil(summary.topicImages) XCTAssertNil(summary.references) @@ -527,16 +552,17 @@ class ExternalLinkableTests: XCTestCase { XCTAssertEqual(variant.language, .objectiveC) XCTAssertEqual(variant.title, "myStringFunction:error:") XCTAssertEqual(variant.declarationFragments, [ - .init(text: "typedef", kind: .keyword, identifier: nil), - .init(text: " ", kind: .text, identifier: nil), - .init(text: "enum", kind: .keyword, identifier: nil), - .init(text: " ", kind: .text, identifier: nil), - .init(text: "Foo", kind: .identifier, identifier: nil), - .init(text: " : ", kind: .text, identifier: nil), - .init(text: "NSString", kind: .typeIdentifier, identifier: nil, preciseIdentifier: Optional("c:@T@NSInteger")), - .init(text: " {\n ...\n} ", kind: .text, identifier: nil), - .init(text: "Foo", kind: .identifier, identifier: nil), - .init(text: ";", kind: .text, identifier: nil) + .init(text: "+ (", kind: .text, identifier: nil), + .init(text: "NSString", kind: .typeIdentifier, identifier: nil, preciseIdentifier: "c:objc(cs)NSString"), + .init(text: " *) ", kind: .text, identifier: nil), + .init(text: "myStringFunction", kind: .identifier, identifier: nil), + .init(text: ": (", kind: .text, identifier: nil), + .init(text: "NSString", kind: .typeIdentifier, identifier: nil, preciseIdentifier: "c:objc(cs)NSString"), + .init(text: " *)string", kind: .text, identifier: nil), + .init(text: "error", kind: .identifier, identifier: nil), + .init(text: ": (", kind: .text, identifier: nil), + .init(text: "NSError", kind: .typeIdentifier, identifier: nil, preciseIdentifier: "c:objc(cs)NSError"), + .init(text: " **)error;", kind: .text, identifier: nil) ]) // Check variant content that is the same as the summarized element diff --git a/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift b/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift index f50440a8bb..a903f0d6b1 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift @@ -237,9 +237,7 @@ class RenderNodeTranslatorTests: XCTestCase { let article = try XCTUnwrap( Article(from: document.root, source: nil, for: bundle, in: context, problems: &problems) ) - let translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleIdentifier: "org.swift.docc.example", path: "/article", fragment: nil, sourceLanguage: .swift), source: nil) - - XCTAssertEqual(RenderMetadata.Role.article, translator.contentRenderer.roleForArticle(article, nodeKind: .article)) + XCTAssertEqual(RenderMetadata.Role.article, DocumentationContentRenderer.roleForArticle(article, nodeKind: .article)) } // Verify collections' role @@ -254,13 +252,12 @@ class RenderNodeTranslatorTests: XCTestCase { - """ let document = Document(parsing: source, options: .parseBlockDirectives) - let translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleIdentifier: "org.swift.docc.example", path: "/article", fragment: nil, sourceLanguage: .swift), source: nil) // Verify a collection group let article1 = try XCTUnwrap( Article(from: document.root, source: nil, for: bundle, in: context, problems: &problems) ) - XCTAssertEqual(RenderMetadata.Role.collectionGroup, translator.contentRenderer.roleForArticle(article1, nodeKind: .article)) + XCTAssertEqual(RenderMetadata.Role.collectionGroup, DocumentationContentRenderer.roleForArticle(article1, nodeKind: .article)) let metadataSource = """ @Metadata { @@ -276,7 +273,7 @@ class RenderNodeTranslatorTests: XCTestCase { let article2 = try XCTUnwrap( Article(from: metadataDocument.root, source: nil, for: bundle, in: context, problems: &problems) ) - XCTAssertEqual(RenderMetadata.Role.collection, translator.contentRenderer.roleForArticle(article2, nodeKind: .article)) + XCTAssertEqual(RenderMetadata.Role.collection, DocumentationContentRenderer.roleForArticle(article2, nodeKind: .article)) } } @@ -304,7 +301,7 @@ class RenderNodeTranslatorTests: XCTestCase { XCTAssertEqual(renderReference.abstract.first?.plainText, "This is the tutorial abstract.") } - func testEmtpyTaskGroupsNotRendered() throws { + func testEmptyTaskGroupsNotRendered() throws { let (bundle, context) = try testBundleAndContext(named: "TestBundle") var problems = [Problem]() diff --git a/Tests/SwiftDocCTests/TestRenderNodeOutputConsumer.swift b/Tests/SwiftDocCTests/TestRenderNodeOutputConsumer.swift index 3c0b8eb56b..caa5f6a4b8 100644 --- a/Tests/SwiftDocCTests/TestRenderNodeOutputConsumer.swift +++ b/Tests/SwiftDocCTests/TestRenderNodeOutputConsumer.swift @@ -30,6 +30,7 @@ class TestRenderNodeOutputConsumer: ConvertOutputConsumer { func consume(documentationCoverageInfo: [CoverageDataEntry]) throws { } func consume(renderReferenceStore: RenderReferenceStore) throws { } func consume(buildMetadata: BuildMetadata) throws { } + func consume(linkResolutionInformation: SerializableLinkResolutionInformation) throws { } } extension TestRenderNodeOutputConsumer { diff --git a/Tests/SwiftDocCUtilitiesTests/ArgumentParsing/ConvertSubcommandTests.swift b/Tests/SwiftDocCUtilitiesTests/ArgumentParsing/ConvertSubcommandTests.swift index fc3ed07845..f54c7961f4 100644 --- a/Tests/SwiftDocCUtilitiesTests/ArgumentParsing/ConvertSubcommandTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/ArgumentParsing/ConvertSubcommandTests.swift @@ -389,6 +389,105 @@ class ConvertSubcommandTests: XCTestCase { XCTAssertTrue(FeatureFlags.current.isExperimentalDeviceFrameSupportEnabled) } + func testExperimentalEnableExternalLinkSupportFlag() throws { + let originalFeatureFlagsState = FeatureFlags.current + defer { + FeatureFlags.current = originalFeatureFlagsState + } + + let commandWithoutFlag = try Docc.Convert.parse([testBundleURL.path]) + _ = try ConvertAction(fromConvertCommand: commandWithoutFlag) + XCTAssertFalse(commandWithoutFlag.enableExperimentalLinkHierarchySerialization) + XCTAssertFalse(FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled) + + let commandWithFlag = try Docc.Convert.parse([ + "--enable-experimental-external-link-support", + testBundleURL.path, + ]) + _ = try ConvertAction(fromConvertCommand: commandWithFlag) + XCTAssertTrue(commandWithFlag.enableExperimentalLinkHierarchySerialization) + XCTAssertTrue(FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled) + } + + func testLinkDependencyValidation() throws { + let originalErrorLogHandle = Docc.Convert._errorLogHandle + let originalDiagnosticFormattingOptions = Docc.Convert._diagnosticFormattingOptions + defer { + Docc.Convert._errorLogHandle = originalErrorLogHandle + Docc.Convert._diagnosticFormattingOptions = originalDiagnosticFormattingOptions + } + Docc.Convert._diagnosticFormattingOptions = .formatConsoleOutputForTools + + let rendererTemplateDirectory = try createTemporaryDirectory() + try "".write(to: rendererTemplateDirectory.appendingPathComponent("index.html"), atomically: true, encoding: .utf8) + SetEnvironmentVariable(TemplateOption.environmentVariableKey, rendererTemplateDirectory.path) + defer { + UnsetEnvironmentVariable(TemplateOption.environmentVariableKey) + } + + let dependencyDir = try createTemporaryDirectory() + .appendingPathComponent("SomeDependency.doccarchive", isDirectory: true) + let fileManager = FileManager.default + + let argumentsToParse = [ + testBundleURL.path, + "--dependency", + dependencyDir.path + ] + + // The dependency doesn't exist + do { + let logStorage = LogHandle.LogStorage() + Docc.Convert._errorLogHandle = .memory(logStorage) + + let command = try Docc.Convert.parse(argumentsToParse) + XCTAssertEqual(command.linkResolutionOptions.dependencies, []) + XCTAssertEqual(logStorage.text.trimmingCharacters(in: .newlines), """ + warning: No documentation archive exist at '\(dependencyDir.path)'. + """) + } + // The dependency is a file instead of a directory + do { + let logStorage = LogHandle.LogStorage() + Docc.Convert._errorLogHandle = .memory(logStorage) + + try "Some text".write(to: dependencyDir, atomically: true, encoding: .utf8) + + let command = try Docc.Convert.parse(argumentsToParse) + XCTAssertEqual(command.linkResolutionOptions.dependencies, []) + XCTAssertEqual(logStorage.text.trimmingCharacters(in: .newlines), """ + warning: Dependency at '\(dependencyDir.path)' is not a directory. + """) + + try fileManager.removeItem(at: dependencyDir) + } + // The dependency doesn't have the necessary files + do { + let logStorage = LogHandle.LogStorage() + Docc.Convert._errorLogHandle = .memory(logStorage) + + try fileManager.createDirectory(at: dependencyDir, withIntermediateDirectories: false) + + let command = try Docc.Convert.parse(argumentsToParse) + XCTAssertEqual(command.linkResolutionOptions.dependencies, []) + XCTAssertEqual(logStorage.text.trimmingCharacters(in: .newlines), """ + warning: Dependency at '\(dependencyDir.path)' doesn't contain a is not a 'linkable-entities.json' file. + warning: Dependency at '\(dependencyDir.path)' doesn't contain a is not a 'link-hierarchy.json' file. + """) + } + do { + let logStorage = LogHandle.LogStorage() + Docc.Convert._errorLogHandle = .memory(logStorage) + + try "".write(to: dependencyDir.appendingPathComponent("linkable-entities.json"), atomically: true, encoding: .utf8) + try "".write(to: dependencyDir.appendingPathComponent("link-hierarchy.json"), atomically: true, encoding: .utf8) + + let command = try Docc.Convert.parse(argumentsToParse) + XCTAssertEqual(command.linkResolutionOptions.dependencies, [dependencyDir]) + XCTAssertEqual(logStorage.text.trimmingCharacters(in: .newlines), "") + } + } + func testTransformForStaticHostingFlagWithoutHTMLTemplate() throws { UnsetEnvironmentVariable(TemplateOption.environmentVariableKey) diff --git a/features.json b/features.json index be2e6d24b7..ef7a583ece 100644 --- a/features.json +++ b/features.json @@ -2,6 +2,9 @@ "features": [ { "name": "diagnostics-file" + }, + { + "name": "dependency" } ] }