Skip to content

Commit dc0f2e2

Browse files
committed
Add manually curated non-symbol nodes in navigator
PR #757 enabled DocC to include manually curated non-symbol nodes in the topics section, irrespective of the node language. This allows curating articles across documentation in different languages, even though the article's language is considered to be "Swift". The change was not propagated to the navigator, resulting in the references being present in the topics section but not in the sidebar. This patch adds these references to the navigator. It also ensures that the logic is tested in the same way as the topics section logic to maintain consistency. rdar://160284853
1 parent 9cc582b commit dc0f2e2

File tree

3 files changed

+200
-13
lines changed

3 files changed

+200
-13
lines changed

Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,17 @@ extension NavigatorIndex {
466466
self.fragment = fragment
467467
self.languageIdentifier = languageIdentifier
468468
}
469+
470+
/// Compare an identifier with another one, ignoring the identifier language.
471+
///
472+
/// Used when curating cross-language references in multi-language frameworks.
473+
///
474+
/// - Parameter other: The other identifier to compare with.
475+
func isEquivalentIgnoringLanguage(to other: Identifier) -> Bool {
476+
return self.bundleIdentifier == other.bundleIdentifier &&
477+
self.path == other.path &&
478+
self.fragment == other.fragment
479+
}
469480
}
470481

471482
/**
@@ -949,7 +960,20 @@ extension NavigatorIndex {
949960
let (nodeID, parent) = nodesMultiCurated[index]
950961
let placeholders = identifierToChildren[nodeID]!
951962
for reference in placeholders {
952-
if let child = identifierToNode[reference] {
963+
var child = identifierToNode[reference]
964+
// If no node is found for the reference, look for nodes that match the identifier ignoring the language.
965+
// This is valid in case of references to nodes that are not symbols. Since the identifier is assumed to
966+
// be of the same language mask as the parent node, references to articles or frameworks will be missed
967+
// during the node lookup, even though they are valid. If a node with the same identifier in a different
968+
// language is found within the same bundle, that node is used for the reference.
969+
if child == nil {
970+
child = identifierToNode
971+
.first { identifier, node in
972+
identifier.isEquivalentIgnoringLanguage(to: reference)
973+
&& PageType.init(rawValue: node.item.pageType)?.isSymbolKind == false
974+
}?.value
975+
}
976+
if let child = child {
953977
parent.add(child: child)
954978
pendingUncuratedReferences.remove(reference)
955979
if !multiCurated.keys.contains(reference) && reference.fragment == nil {
@@ -970,7 +994,20 @@ extension NavigatorIndex {
970994
for (nodeIdentifier, placeholders) in identifierToChildren {
971995
for reference in placeholders {
972996
let parent = identifierToNode[nodeIdentifier]!
973-
if let child = identifierToNode[reference] {
997+
var child = identifierToNode[reference]
998+
// If no node is found for the reference, look for nodes that match the identifier ignoring the language.
999+
// This is valid in case of references to nodes that are not symbols. Since the identifier is assumed to
1000+
// be of the same language mask as the parent node, references to articles or frameworks will be missed
1001+
// during the node lookup, even though they are valid. If a node with the same identifier in a different
1002+
// language is found within the same bundle, that node is used for the reference.
1003+
if child == nil {
1004+
child = identifierToNode
1005+
.first { identifier, node in
1006+
identifier.isEquivalentIgnoringLanguage(to: reference)
1007+
&& PageType.init(rawValue: node.item.pageType)?.isSymbolKind == false
1008+
}?.value
1009+
}
1010+
if let child = child {
9741011
let needsCopy = multiCurated[reference] != nil
9751012
parent.add(child: (needsCopy) ? child.copy() : child)
9761013
pendingUncuratedReferences.remove(reference)
@@ -1001,14 +1038,11 @@ extension NavigatorIndex {
10011038
// page types as symbol nodes on the assumption that an unknown page type is a
10021039
// symbol kind added in a future version of Swift-DocC.
10031040
// Finally, don't add external references to the root; if they are not referenced within the navigation tree, they should be dropped altogether.
1004-
if let node = identifierToNode[nodeID], PageType(rawValue: node.item.pageType)?.isSymbolKind == false , !node.item.isExternal {
1041+
if let node = identifierToNode[nodeID], PageType(rawValue: node.item.pageType)?.isSymbolKind == false, !node.item.isExternal {
10051042

10061043
// If an uncurated page has been curated in another language, don't add it to the top-level.
10071044
if curatedReferences.contains(where: { curatedNodeID in
1008-
// Compare all the identifier's properties for equality, except for its language.
1009-
curatedNodeID.bundleIdentifier == nodeID.bundleIdentifier
1010-
&& curatedNodeID.path == nodeID.path
1011-
&& curatedNodeID.fragment == nodeID.fragment
1045+
curatedNodeID.isEquivalentIgnoringLanguage(to: nodeID)
10121046
}) {
10131047
continue
10141048
}

Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,11 @@ class ExternalRenderNodeTests: XCTestCase {
202202
// Verify that the curated external links are part of the index.
203203
let swiftExternalNodes = renderIndex.interfaceLanguages["swift"]?.first { $0.path == "/documentation/mixedlanguageframework" }?.children?.filter { $0.path?.contains("/path/to/external") ?? false } ?? []
204204
let occExternalNodes = renderIndex.interfaceLanguages["occ"]?.first { $0.path == "/documentation/mixedlanguageframework" }?.children?.filter { $0.path?.contains("/path/to/external") ?? false } ?? []
205-
XCTAssertEqual(swiftExternalNodes.count, 2)
206-
XCTAssertEqual(occExternalNodes.count, 2)
207-
XCTAssertEqual(swiftExternalNodes.map(\.title), ["SwiftArticle", "SwiftSymbol"])
208-
XCTAssertEqual(occExternalNodes.map(\.title), ["ObjCArticle", "ObjCSymbol"])
205+
XCTAssertEqual(swiftExternalNodes.count, 3)
206+
XCTAssertEqual(occExternalNodes.count, 3)
207+
// Articles should be curated all the time irrespective of their language
208+
XCTAssertEqual(swiftExternalNodes.map(\.title), ["SwiftArticle", "SwiftSymbol", "ObjCArticle"])
209+
XCTAssertEqual(occExternalNodes.map(\.title), ["SwiftArticle", "ObjCArticle", "ObjCSymbol"])
209210
XCTAssert(swiftExternalNodes.allSatisfy(\.isExternal))
210211
XCTAssert(occExternalNodes.allSatisfy(\.isExternal))
211212
}
@@ -263,9 +264,10 @@ class ExternalRenderNodeTests: XCTestCase {
263264
let swiftExternalNodes = renderIndex.interfaceLanguages["swift"]?.first { $0.path == "/documentation/mixedlanguageframework" }?.children?.filter { $0.path?.contains("/path/to/external") ?? false } ?? []
264265
let occExternalNodes = renderIndex.interfaceLanguages["occ"]?.first { $0.path == "/documentation/mixedlanguageframework" }?.children?.filter { $0.path?.contains("/path/to/external") ?? false } ?? []
265266
XCTAssertEqual(swiftExternalNodes.count, 1)
266-
XCTAssertEqual(occExternalNodes.count, 1)
267267
XCTAssertEqual(swiftExternalNodes.map(\.title), ["SwiftArticle"])
268-
XCTAssertEqual(occExternalNodes.map(\.title), ["ObjCSymbol"])
268+
// Articles should be curated all the time irrespective of their language
269+
XCTAssertEqual(occExternalNodes.count, 2)
270+
XCTAssertEqual(occExternalNodes.map(\.title), ["SwiftArticle", "ObjCSymbol"])
269271
XCTAssert(swiftExternalNodes.allSatisfy(\.isExternal))
270272
XCTAssert(occExternalNodes.allSatisfy(\.isExternal))
271273
}

Tests/SwiftDocCTests/Indexing/NavigatorIndexTests.swift

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2008,6 +2008,157 @@ Root
20082008

20092009
return navigatorIndex
20102010
}
2011+
2012+
func testCrossLanguageCurationBehavior() async throws {
2013+
let catalog = Folder(name: "CrossLanguageTest.docc", content: [
2014+
InfoPlist(identifier: "org.swift.docc.crosslanguagetest"),
2015+
// Symbol graph with both Swift and Objective-C symbols
2016+
JSONFile(name: "CrossLanguageFramework.symbols.json", content: makeSymbolGraph(
2017+
moduleName: "CrossLanguageFramework",
2018+
symbols: [
2019+
.init(
2020+
identifier: .init(precise: "swift-class-id", interfaceLanguage: SourceLanguage.swift.id),
2021+
names: .init(title: "SwiftClass", navigator: [.init(kind: .identifier, spelling: "SwiftClass", preciseIdentifier: nil)], subHeading: nil, prose: nil),
2022+
pathComponents: ["SwiftClass"],
2023+
docComment: nil,
2024+
accessLevel: .public,
2025+
kind: .init(parsedIdentifier: .class, displayName: "Class"),
2026+
mixins: [:]
2027+
),
2028+
.init(
2029+
identifier: .init(precise: "objc-class-id", interfaceLanguage: SourceLanguage.objectiveC.id),
2030+
names: .init(title: "ObjCClass", navigator: [.init(kind: .identifier, spelling: "ObjCClass", preciseIdentifier: nil)], subHeading: nil, prose: nil),
2031+
pathComponents: ["ObjCClass"],
2032+
docComment: nil,
2033+
accessLevel: .public,
2034+
kind: .init(parsedIdentifier: .class, displayName: "Class"),
2035+
mixins: [:]
2036+
)
2037+
],
2038+
relationships: []
2039+
)),
2040+
TextFile(name: "CrossLanguageArticle.md", utf8Content: """
2041+
# Cross Language Article
2042+
2043+
This is a general article.
2044+
"""),
2045+
TextFile(name: "SwiftOnlyArticle.md", utf8Content: """
2046+
# Swift Only Article
2047+
2048+
@Metadata {
2049+
@SupportedLanguage(swift)
2050+
}
2051+
2052+
This is a Swift-only article.
2053+
"""),
2054+
TextFile(name: "ObjCOnlyArticle.md", utf8Content: """
2055+
# Objective-C Only Article
2056+
2057+
@Metadata {
2058+
@SupportedLanguage(objc)
2059+
}
2060+
2061+
This article is only available in Objective-C.
2062+
"""),
2063+
// Swift page that curates ObjC articles and symbols
2064+
TextFile(name: "SwiftContainer.md", utf8Content: """
2065+
# Swift Container
2066+
2067+
@Metadata {
2068+
@SupportedLanguage(swift)
2069+
}
2070+
2071+
This page curates articles and attempts to curate Objective-C symbols (which should be dropped).
2072+
2073+
## Topics
2074+
2075+
### Articles
2076+
- <doc:CrossLanguageArticle>
2077+
- <doc:ObjCOnlyArticle>
2078+
2079+
### Swift Symbols
2080+
- ``SwiftClass``
2081+
2082+
### Objective-C Symbols
2083+
- ``ObjCClass``
2084+
"""),
2085+
// ObjC page that curates Swift articles and symbols
2086+
TextFile(name: "ObjCContainer.md", utf8Content: """
2087+
# Objective-C Container
2088+
2089+
@Metadata {
2090+
@SupportedLanguage(objc)
2091+
}
2092+
2093+
This page curates articles and attempts to curate Swift symbols (which should be dropped).
2094+
2095+
## Topics
2096+
2097+
### Articles
2098+
- <doc:CrossLanguageArticle>
2099+
- <doc:SwiftOnlyArticle>
2100+
2101+
### Objective-C Symbols
2102+
- ``ObjCClass``
2103+
2104+
### Swift Symbols
2105+
- ``SwiftClass``
2106+
""")
2107+
])
2108+
2109+
let (_, context) = try await loadBundle(catalog: catalog)
2110+
let bundle = try XCTUnwrap(context.bundle)
2111+
2112+
XCTAssert(context.problems.isEmpty, "Failed to unwrap bundle: \(context.problems.map(\.diagnostic.summary))")
2113+
2114+
// Generate navigator index
2115+
let targetURL = try createTemporaryDirectory()
2116+
let builder = NavigatorIndex.Builder(outputURL: targetURL, bundleIdentifier: "org.swift.docc.crosslanguagetest", groupByLanguage: true)
2117+
builder.setup()
2118+
2119+
let renderContext = RenderContext(documentationContext: context, bundle: bundle)
2120+
let converter = DocumentationContextConverter(bundle: bundle, context: context, renderContext: renderContext)
2121+
2122+
for identifier in context.knownPages {
2123+
let entity = try context.entity(with: identifier)
2124+
let renderNode = try XCTUnwrap(converter.renderNode(for: entity))
2125+
try builder.index(renderNode: renderNode)
2126+
}
2127+
2128+
builder.finalize()
2129+
let navigatorIndex = try XCTUnwrap(builder.navigatorIndex)
2130+
2131+
let swiftNode = try XCTUnwrap(navigatorIndex.navigatorTree.root.children.first { $0.item.title == "Swift" })
2132+
let swiftContainer = try XCTUnwrap(search(node: swiftNode) { $0.item.title == "Swift Container" })
2133+
let swiftContainerChildren = Set(swiftContainer.children.map { $0.item.title })
2134+
// Articles should be curated across languages
2135+
XCTAssertTrue(swiftContainerChildren.contains("Cross Language Article"),
2136+
"Cross-language article should be curated in Swift container")
2137+
XCTAssertTrue(swiftContainerChildren.contains("Objective-C Only Article"),
2138+
"Objective-C-only article should be curated in Swift container")
2139+
2140+
// Swift symbols should be curated under Swift
2141+
XCTAssertTrue(swiftContainerChildren.contains("SwiftClass"),
2142+
"Swift symbol should be curated in Swift container")
2143+
// Objective-C symbols should NOT be curated under Swift
2144+
XCTAssertFalse(swiftContainerChildren.contains("ObjCClass"),
2145+
"Objective-C symbol should NOT be curated in Swift container")
2146+
2147+
// Same checks as above for Objective-C
2148+
let objcNode = try XCTUnwrap(navigatorIndex.navigatorTree.root.children.first { $0.item.title == "Objective-C" })
2149+
let objcContainer = try XCTUnwrap(search(node: objcNode) { $0.item.title == "Objective-C Container" })
2150+
let objcContainerChildren = Set(objcContainer.children.map { $0.item.title })
2151+
2152+
XCTAssertTrue(objcContainerChildren.contains("Cross Language Article"),
2153+
"Cross-language article should be curated in Objective-C container")
2154+
XCTAssertTrue(objcContainerChildren.contains("Swift Only Article"),
2155+
"Swift-only article should be curated in Objective-C container")
2156+
2157+
XCTAssertTrue(objcContainerChildren.contains("ObjCClass"),
2158+
"Objective-C symbol should be curated in Objective-C container")
2159+
XCTAssertFalse(objcContainerChildren.contains("SwiftClass"),
2160+
"Swift symbol should NOT be curated in Objective-C container")
2161+
}
20112162
}
20122163

20132164
/// This function compares two nodes to ensure their data is equal.

0 commit comments

Comments
 (0)