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" } ] }