Skip to content

Commit 93a39a6

Browse files
authored
Use only canonical paths for symbol breadcrumbs (#1081)
* Add a function to compute symbol breadcrumbs from the path hierarchy rdar://77922907 * Use only the canonical breadcrumbs for symbols. Like before, the shortest path is preferred regardless of language. * Remove test that verified that all curated paths are included in a symbol's "hierarchy" * Vary the RenderNode/hierarchy for each language representation * Use plural "breadcrumbs" in documentation comment * Add code comment about why nil-values are filtered out * Rename internal parameter name to reflect the correct type of reference * Only decode RenderHierarchy variant when present * Update expected error message in test * Address new `hierarchy` deprecation warnings
1 parent 864453f commit 93a39a6

13 files changed

+332
-79
lines changed

Sources/SwiftDocC/Converter/Rewriter/RemoveHierarchyTransformation.swift

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2021 Apple Inc. and the Swift project authors
4+
Copyright (c) 2021-2024 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See https://swift.org/LICENSE.txt for license information
@@ -18,23 +18,30 @@ import Foundation
1818
public struct RemoveHierarchyTransformation: RenderNodeTransforming {
1919
public init() {}
2020

21-
public func transform(renderNode: RenderNode, context: RenderNodeTransformationContext)
22-
-> RenderNodeTransformationResult {
21+
public func transform(renderNode: RenderNode, context: RenderNodeTransformationContext) -> RenderNodeTransformationResult {
2322
var (renderNode, context) = (renderNode, context)
2423

25-
let identifiersInHierarchy: [String] = {
26-
switch renderNode.hierarchy {
27-
case .reference(let reference):
28-
return reference.paths.flatMap { $0 }
29-
case .tutorials(let tutorial):
30-
return tutorial.paths.flatMap { $0 }
31-
case .none:
32-
return []
24+
let identifiersInHierarchy: Set<String> = {
25+
var collectedIdentifiers: Set<String> = []
26+
27+
// Using `mapValues` here as iteration because manually iterating over the patch structure of variant collections is hard.
28+
_ = renderNode.hierarchyVariants.mapValues { hierarchy in
29+
switch hierarchy {
30+
case .reference(let reference):
31+
collectedIdentifiers.formUnion(reference.paths.flatMap { $0 })
32+
case .tutorials(let tutorial):
33+
collectedIdentifiers.formUnion(tutorial.paths.flatMap { $0 })
34+
case .none:
35+
break
36+
}
37+
return hierarchy
3338
}
39+
40+
return collectedIdentifiers
3441
}()
3542

3643
// Remove hierarchy.
37-
renderNode.hierarchy = nil
44+
renderNode.hierarchyVariants = .init(defaultValue: nil)
3845

3946
// Remove unused references.
4047
for identifier in identifiersInHierarchy {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
import SymbolKit
13+
14+
extension PathHierarchyBasedLinkResolver {
15+
16+
///
17+
/// Finds the canonical path, also called "breadcrumbs", to the given symbol in the path hierarchy.
18+
/// The path is a list of references that describe a walk through the path hierarchy descending from the module down to, but not including, the given `reference`.
19+
///
20+
/// - Parameters:
21+
/// - reference: The symbol reference to find the canonical path to.
22+
/// - sourceLanguage: The source language representation of the symbol to fin the canonical path for.
23+
/// - Returns: The canonical path to the given symbol reference, or `nil` if the reference isn't a symbol or if the symbol doesn't have a representation in the given source language.
24+
func breadcrumbs(of reference: ResolvedTopicReference, in sourceLanguage: SourceLanguage) -> [ResolvedTopicReference]? {
25+
guard let nodeID = resolvedReferenceMap[reference] else { return nil }
26+
var node = pathHierarchy.lookup[nodeID]! // Only the path hierarchy can create its IDs and a created ID always matches a node
27+
28+
func matchesRequestedLanguage(_ node: PathHierarchy.Node) -> Bool {
29+
guard let symbol = node.symbol,
30+
let language = SourceLanguage(knownLanguageIdentifier: symbol.identifier.interfaceLanguage)
31+
else {
32+
return false
33+
}
34+
return language == sourceLanguage
35+
}
36+
37+
if !matchesRequestedLanguage(node) {
38+
guard let counterpart = node.counterpart, matchesRequestedLanguage(counterpart) else {
39+
// Neither this symbol, nor its counterpart matched the requested language
40+
return nil
41+
}
42+
// Traverse from the counterpart instead because it matches the requested language
43+
node = counterpart
44+
}
45+
46+
// Traverse up the hierarchy and gather each reference
47+
return sequence(first: node, next: \.parent)
48+
// The hierarchy traversal happened from the starting point up, but the callers of `breadcrumbs(of:in:)`
49+
// expect paths going from the root page, excluding the starting point itself (already dropped above).
50+
// To match the caller's expectations we remove the starting point and then flip the paths.
51+
.dropFirst().reversed()
52+
.compactMap {
53+
// Ignore any "unfindable" or "sparse" nodes resulting from a "partial" symbol graph.
54+
//
55+
// When the `ConvertService` builds documentation for a single symbol with multiple path components,
56+
// the path hierarchy fills in unfindable nodes for the other path components to construct a connected hierarchy.
57+
//
58+
// These unfindable nodes can be traversed up and down, but are themselves considered to be "not found".
59+
$0.identifier.flatMap { resolvedReferenceMap[$0] }
60+
}
61+
}
62+
}

Sources/SwiftDocC/Model/Rendering/Diffing/RenderNode+Diffable.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ extension RenderNode: RenderJSONDiffable {
2525
diffBuilder.addDifferences(atKeyPath: \.kind, forKey: CodingKeys.kind)
2626
diffBuilder.addDifferences(atKeyPath: \.sections, forKey: CodingKeys.sections)
2727
diffBuilder.addDifferences(atKeyPath: \.references, forKey: CodingKeys.references)
28-
diffBuilder.addDifferences(atKeyPath: \.hierarchy, forKey: CodingKeys.hierarchy)
28+
diffBuilder.addDifferences(atKeyPath: \.hierarchyVariants, forKey: CodingKeys.hierarchy)
2929
diffBuilder.differences.append(contentsOf: metadata.difference(from: other.metadata, at: path + [CodingKeys.metadata])) // RenderMetadata isn't Equatable
3030

3131
// MARK: Reference Documentation Data

Sources/SwiftDocC/Model/Rendering/Navigation Tree/RenderHierarchyTranslator.swift

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,13 @@ struct RenderHierarchyTranslator {
3535
/// - reference: A reference to a tutorials-related topic.
3636
/// - omittingChapters: If `true`, don't include chapters in the returned hierarchy.
3737
/// - Returns: A tuple of 1) a tutorials hierarchy and 2) the root reference of the tutorials hierarchy.
38-
mutating func visitTutorialTableOfContentsNode(_ reference: ResolvedTopicReference, omittingChapters: Bool = false) -> (hierarchy: RenderHierarchy, tutorialTableOfContents: ResolvedTopicReference)? {
38+
mutating func visitTutorialTableOfContentsNode(_ reference: ResolvedTopicReference, omittingChapters: Bool = false) -> (hierarchyVariants: VariantCollection<RenderHierarchy?>, tutorialTableOfContents: ResolvedTopicReference)? {
3939
let paths = context.finitePaths(to: reference, options: [.preferTutorialTableOfContentsRoot])
4040

4141
// If the node is a tutorial table-of-contents page, return immediately without generating breadcrumbs
4242
if let _ = (try? context.entity(with: reference))?.semantic as? TutorialTableOfContents {
4343
let hierarchy = visitTutorialTableOfContents(reference, omittingChapters: omittingChapters)
44-
return (hierarchy: .tutorials(hierarchy), tutorialTableOfContents: reference)
44+
return (hierarchyVariants: .init(defaultValue: .tutorials(hierarchy)), tutorialTableOfContents: reference)
4545
}
4646

4747
guard let tutorialsPath = paths.mapFirst(where: { path -> [ResolvedTopicReference]? in
@@ -62,7 +62,7 @@ struct RenderHierarchyTranslator {
6262
.sorted { lhs, _ in lhs == tutorialsPath }
6363
.map { $0.map { $0.absoluteString } }
6464

65-
return (hierarchy: .tutorials(hierarchy), tutorialTableOfContents: tutorialTableOfContentsReference)
65+
return (hierarchyVariants: .init(defaultValue: .tutorials(hierarchy)), tutorialTableOfContents: tutorialTableOfContentsReference)
6666
}
6767

6868
/// Returns the hierarchy under a given tutorials table-of-contents page.
@@ -192,19 +192,42 @@ struct RenderHierarchyTranslator {
192192
/// The documentation model is a graph (and not a tree) so you can curate API symbols
193193
/// multiple times under other API symbols, articles, or API collections. This method
194194
/// returns all the paths (breadcrumbs) between the framework landing page and the given symbol.
195-
mutating func visitSymbol(_ symbolReference: ResolvedTopicReference) -> RenderHierarchy {
196-
let pathReferences = context.finitePaths(to: symbolReference)
197-
pathReferences.forEach({
198-
collectedTopicReferences.formUnion($0)
199-
})
200-
let paths = pathReferences.map { $0.map { $0.absoluteString } }
201-
return .reference(RenderReferenceHierarchy(paths: paths))
195+
mutating func visitSymbol(_ symbolReference: ResolvedTopicReference) -> VariantCollection<RenderHierarchy?> {
196+
// An inner function used to create a RenderHierarchy from a list of references and collect the unique references
197+
func makeHierarchy(_ references: [ResolvedTopicReference]) -> RenderHierarchy {
198+
collectedTopicReferences.formUnion(references)
199+
let paths = references.map(\.absoluteString)
200+
return .reference(.init(paths: [paths]))
201+
}
202+
203+
let mainPathReferences = context.linkResolver.localResolver.breadcrumbs(of: symbolReference, in: symbolReference.sourceLanguage)
204+
205+
var hierarchyVariants = VariantCollection<RenderHierarchy?>(
206+
defaultValue: mainPathReferences.map(makeHierarchy) // It's possible that the symbol only has a language representation in a variant language
207+
)
208+
209+
for language in symbolReference.sourceLanguages where language != symbolReference.sourceLanguage {
210+
guard let variantPathReferences = context.linkResolver.localResolver.breadcrumbs(of: symbolReference, in: language) else {
211+
continue
212+
}
213+
hierarchyVariants.variants.append(.init(
214+
traits: [.interfaceLanguage(language.id)],
215+
patch: [.replace(value: makeHierarchy(variantPathReferences))]
216+
))
217+
}
218+
219+
return hierarchyVariants
202220
}
203221

204222
/// Returns the hierarchy under a given article.
205-
/// - Parameter symbolReference: The reference to the article.
223+
/// - Parameter articleReference: The reference to the article.
206224
/// - Returns: The framework hierarchy that describes all paths where the article is curated.
207-
mutating func visitArticle(_ symbolReference: ResolvedTopicReference) -> RenderHierarchy {
208-
return visitSymbol(symbolReference)
225+
mutating func visitArticle(_ articleReference: ResolvedTopicReference) -> VariantCollection<RenderHierarchy?> {
226+
let pathReferences = context.finitePaths(to: articleReference)
227+
pathReferences.forEach({
228+
collectedTopicReferences.formUnion($0)
229+
})
230+
let paths = pathReferences.map { $0.map { $0.absoluteString } }
231+
return .init(defaultValue: .reference(RenderReferenceHierarchy(paths: paths)))
209232
}
210233
}

Sources/SwiftDocC/Model/Rendering/RenderNode.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,14 @@ public struct RenderNode: VariantContainer {
126126
/// The key for each reference is the ``RenderReferenceIdentifier/identifier`` of the reference's ``RenderReference/identifier``.
127127
public var references: [String: RenderReference] = [:]
128128

129+
@available(*, deprecated, message: "Use 'hierarchyVariants' instead. This deprecated API will be removed after 6.2 is released")
130+
public var hierarchy: RenderHierarchy? {
131+
get { hierarchyVariants.defaultValue }
132+
set { hierarchyVariants.defaultValue = newValue }
133+
}
134+
129135
/// Hierarchy information about the context in which this documentation node is placed.
130-
public var hierarchy: RenderHierarchy?
136+
public var hierarchyVariants: VariantCollection<RenderHierarchy?> = .init(defaultValue: nil)
131137

132138
/// Arbitrary metadata information about the render node.
133139
public var metadata = RenderMetadata()

Sources/SwiftDocC/Model/Rendering/RenderNode/RenderNode+Codable.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ extension RenderNode: Codable {
2525
references = try (container.decodeIfPresent([String: CodableRenderReference].self, forKey: .references) ?? [:]).mapValues({$0.reference})
2626
metadata = try container.decode(RenderMetadata.self, forKey: .metadata)
2727
kind = try container.decode(Kind.self, forKey: .kind)
28-
hierarchy = try container.decodeIfPresent(RenderHierarchy.self, forKey: .hierarchy)
28+
hierarchyVariants = try container.decodeVariantCollectionIfPresent(
29+
ofValueType: RenderHierarchy?.self,
30+
forKey: .hierarchy
31+
) ?? .init(defaultValue: nil)
2932
topicSectionsStyle = try container.decodeIfPresent(TopicsSectionStyle.self, forKey: .topicSectionsStyle) ?? .list
3033

3134
primaryContentSectionsVariants = try container.decodeVariantCollectionArrayIfPresent(
@@ -79,7 +82,7 @@ extension RenderNode: Codable {
7982

8083
try container.encode(metadata, forKey: .metadata)
8184
try container.encode(kind, forKey: .kind)
82-
try container.encode(hierarchy, forKey: .hierarchy)
85+
try container.encodeVariantCollection(hierarchyVariants, forKey: .hierarchy, encoder: encoder)
8386
if topicSectionsStyle != .list {
8487
try container.encode(topicSectionsStyle, forKey: .topicSectionsStyle)
8588
}

Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
138138

139139
if let hierarchy = hierarchyTranslator.visitTutorialTableOfContentsNode(identifier) {
140140
let tutorialTableOfContents = try! context.entity(with: hierarchy.tutorialTableOfContents).semantic as! TutorialTableOfContents
141-
node.hierarchy = hierarchy.hierarchy
141+
node.hierarchyVariants = hierarchy.hierarchyVariants
142142
node.metadata.category = tutorialTableOfContents.name
143143
node.metadata.categoryPathComponent = hierarchy.tutorialTableOfContents.url.lastPathComponent
144144
} else if !context.configuration.convertServiceConfiguration.allowsRegisteringArticlesWithoutTechnologyRoot {
@@ -402,11 +402,10 @@ public struct RenderNodeTranslator: SemanticVisitor {
402402
}
403403

404404
var hierarchyTranslator = RenderHierarchyTranslator(context: context, bundle: bundle)
405-
node.hierarchy = hierarchyTranslator
406-
.visitTutorialTableOfContentsNode(identifier, omittingChapters: true)!
407-
.hierarchy
408-
409-
collectedTopicReferences.append(contentsOf: hierarchyTranslator.collectedTopicReferences)
405+
if let (hierarchyVariants, _) = hierarchyTranslator.visitTutorialTableOfContentsNode(identifier, omittingChapters: true) {
406+
node.hierarchyVariants = hierarchyVariants
407+
collectedTopicReferences.append(contentsOf: hierarchyTranslator.collectedTopicReferences)
408+
}
410409

411410
node.references = createTopicRenderReferences()
412411

@@ -626,9 +625,9 @@ public struct RenderNodeTranslator: SemanticVisitor {
626625
let documentationNode = try! context.entity(with: identifier)
627626

628627
var hierarchyTranslator = RenderHierarchyTranslator(context: context, bundle: bundle)
629-
let hierarchy = hierarchyTranslator.visitArticle(identifier)
628+
let hierarchyVariants = hierarchyTranslator.visitArticle(identifier)
630629
collectedTopicReferences.append(contentsOf: hierarchyTranslator.collectedTopicReferences)
631-
node.hierarchy = hierarchy
630+
node.hierarchyVariants = hierarchyVariants
632631

633632
// Emit variants only if we're not compiling an article-only catalog to prevent renderers from
634633
// advertising the page as "Swift", which is the language DocC assigns to pages in article only pages.
@@ -896,7 +895,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
896895

897896
// Unlike for other pages, in here we use `RenderHierarchyTranslator` to crawl the technology
898897
// and produce the list of modules for the render hierarchy to display in the tutorial local navigation.
899-
node.hierarchy = hierarchy.hierarchy
898+
node.hierarchyVariants = hierarchy.hierarchyVariants
900899

901900
let documentationNode = try! context.entity(with: identifier)
902901
node.variants = variants(for: documentationNode)
@@ -1334,9 +1333,9 @@ public struct RenderNodeTranslator: SemanticVisitor {
13341333
node.metadata.tags = contentRenderer.tags(for: identifier)
13351334

13361335
var hierarchyTranslator = RenderHierarchyTranslator(context: context, bundle: bundle)
1337-
let hierarchy = hierarchyTranslator.visitSymbol(identifier)
1336+
let hierarchyVariants = hierarchyTranslator.visitSymbol(identifier)
13381337
collectedTopicReferences.append(contentsOf: hierarchyTranslator.collectedTopicReferences)
1339-
node.hierarchy = hierarchy
1338+
node.hierarchyVariants = hierarchyVariants
13401339

13411340
// In case `inheritDocs` is disabled and there is actually origin data for the symbol, then include origin information as abstract.
13421341
// Generate the placeholder abstract only in case there isn't an authored abstract coming from a doc extension.

Tests/SwiftDocCTests/Converter/RenderNodeTransformerTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ class RenderNodeTransformerTests: XCTestCase {
7979
XCTAssertEqual(renderNode.metadata.title, "test title")
8080
XCTAssertEqual(renderNode.metadata.roleHeading, "test heading")
8181

82-
XCTAssertNil(renderNode.hierarchy)
82+
XCTAssertNil(renderNode.hierarchyVariants.defaultValue)
8383
XCTAssertNil(renderNode.references["doc://org.swift.docc.example/documentation/MyKit"])
8484

8585
}

Tests/SwiftDocCTests/Converter/Rewriter/RenderNodeVariantOverridesApplierTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ class RenderNodeVariantOverridesApplierTests: XCTestCase {
144144
error.localizedDescription,
145145
"""
146146
Invalid dictionary pointer '/foo'. The component 'foo' is not valid for the object with keys \
147-
'hierarchy', 'identifier', 'kind', 'metadata', 'references', 'schemaVersion', 'sections', and \
147+
'identifier', 'kind', 'metadata', 'references', 'schemaVersion', 'sections', and \
148148
'variantOverrides'.
149149
"""
150150
)

0 commit comments

Comments
 (0)