Skip to content

Commit ba3d81d

Browse files
authored
Fix crash where multi-language symbols could get parents not in the tree (#759)
* Fix crash where multi-language symbols could get parents not in the tree rdar://118892994 * Add additional assertion that the node that's skipped is already added * Add parenthesis to logic expression for readability
1 parent 2084946 commit ba3d81d

File tree

2 files changed

+130
-31
lines changed

2 files changed

+130
-31
lines changed

Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,12 @@ struct PathHierarchy {
102102
var nodes: [String: Node] = [:]
103103
nodes.reserveCapacity(graph.symbols.count)
104104
for (id, symbol) in graph.symbols {
105-
if let existingNode = allNodes[id]?.first(where: { $0.symbol!.identifier == symbol.identifier }) {
105+
if let existingNode = allNodes[id]?.first(where: {
106+
// If both identifiers are in the same language, they are the same symbol
107+
$0.symbol!.identifier.interfaceLanguage == symbol.identifier.interfaceLanguage
108+
// Otherwise, if both have the same name and kind their differences doesn't matter for link resolution purposes
109+
|| ($0.name == symbol.pathComponents.last && $0.symbol!.kind.identifier == symbol.kind.identifier)
110+
}) {
106111
nodes[id] = existingNode
107112
} else {
108113
assert(!symbol.pathComponents.isEmpty, "A symbol should have at least its own name in its path components.")
@@ -201,9 +206,21 @@ struct PathHierarchy {
201206
}
202207

203208
assert(
204-
allNodes.allSatisfy({ $0.value[0].parent != nil || roots[$0.key] != nil }),
205-
"Every node should either have a parent node or be a root node. This wasn't true for \(allNodes.filter({ $0.value[0].parent != nil || roots[$0.key] != nil }).map(\.key).sorted())"
209+
allNodes.allSatisfy({ $0.value[0].parent != nil || roots[$0.key] != nil }), """
210+
Every node should either have a parent node or be a root node. \
211+
This wasn't true for \(allNodes.filter({ $0.value[0].parent != nil || roots[$0.key] != nil }).map(\.key).sorted())
212+
"""
206213
)
214+
215+
assert(
216+
allNodes.values.allSatisfy({ nodesWithSameUSR in nodesWithSameUSR.allSatisfy({ node in
217+
Array(sequence(first: node, next: \.parent)).last!.symbol!.kind.identifier == .module })
218+
}), """
219+
Every node should reach a root node by following its parents up. \
220+
This wasn't true for \(allNodes.filter({ $0.value.allSatisfy({ Array(sequence(first: $0, next: \.parent)).last!.symbol!.kind.identifier == .module }) }).map(\.key).sorted())
221+
"""
222+
)
223+
207224
allNodes.removeAll()
208225

209226
// build the lookup list by traversing the hierarchy and adding identifiers to each node
@@ -220,8 +237,21 @@ struct PathHierarchy {
220237
}
221238
for tree in node.children.values {
222239
for (_, subtree) in tree.storage {
223-
for (_, node) in subtree {
224-
descend(node)
240+
for (_, childNode) in subtree {
241+
assert(childNode.parent === node, {
242+
func describe(_ node: Node?) -> String {
243+
guard let node = node else { return "<nil>" }
244+
guard let identifier = node.symbol?.identifier else { return node.name }
245+
return "\(identifier.precise) (\(identifier.interfaceLanguage))"
246+
}
247+
return """
248+
Every child node should point back to its parent so that the tree can be traversed both up and down without any dead-ends. \
249+
This wasn't true for '\(describe(childNode))' which pointed to '\(describe(childNode.parent))' but should have pointed to '\(describe(node))'.
250+
""" }()
251+
)
252+
// In release builds we close off any dead-ends in the tree as a precaution for what shouldn't happen.
253+
childNode.parent = node
254+
descend(childNode)
225255
}
226256
}
227257
}
@@ -231,6 +261,13 @@ struct PathHierarchy {
231261
descend(module)
232262
}
233263

264+
assert(
265+
lookup.allSatisfy({ $0.value.parent != nil || roots[$0.value.name] != nil }), """
266+
Every node should either have a parent node or be a root node. \
267+
This wasn't true for \(allNodes.filter({ $0.value[0].parent != nil || roots[$0.key] != nil }).map(\.key).sorted())
268+
"""
269+
)
270+
234271
func newNode(_ name: String) -> Node {
235272
let id = ResolvedIdentifier()
236273
let node = Node(name: name)
@@ -247,6 +284,13 @@ struct PathHierarchy {
247284
"Every node lookup should match a node with that identifier."
248285
)
249286

287+
assert(
288+
lookup.values.allSatisfy({ $0.parent?.identifier == nil || lookup[$0.parent!.identifier] != nil }), """
289+
Every node's findable parent should exist in the lookup. \
290+
This wasn't true for \(lookup.values.filter({ $0.parent?.identifier == nil || lookup[$0.parent!.identifier] != nil }).map(\.symbol!.identifier.precise).sorted())
291+
"""
292+
)
293+
250294
self.modules = roots
251295
self.lookup = lookup
252296

@@ -327,7 +371,7 @@ extension PathHierarchy {
327371
/// Each name maps to a disambiguation tree that handles
328372
private(set) var children: [String: DisambiguationContainer]
329373

330-
private(set) unowned var parent: Node?
374+
fileprivate(set) unowned var parent: Node?
331375
/// The symbol, if a node has one.
332376
private(set) var symbol: SymbolGraph.Symbol?
333377

@@ -367,9 +411,18 @@ extension PathHierarchy {
367411

368412
/// Adds a descendant of this node.
369413
fileprivate func add(child: Node, kind: String?, hash: String?) {
414+
guard child.parent !== self else {
415+
assert(
416+
(try? children[child.name]?.find(kind, hash)) === child,
417+
"If the new child node already has this node as its parent it should already exist among this node's children."
418+
)
419+
return
420+
}
370421
// If the name was passed explicitly, then the node could have spaces in its name
371422
child.parent = self
372423
children[child.name, default: .init()].add(kind ?? "_", hash ?? "_", child)
424+
425+
assert(child.parent === self, "Potentially merging nodes shouldn't break the child node's reference to its parent.")
373426
}
374427

375428
/// Combines this node with another node.

Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift

Lines changed: 71 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,31 +1544,11 @@ class PathHierarchyTests: XCTestCase {
15441544
["X", "Y"],
15451545
["X", "Y2", "Z", "W"],
15461546
]
1547-
let graph = SymbolGraph(
1548-
metadata: SymbolGraph.Metadata(
1549-
formatVersion: SymbolGraph.SemanticVersion(major: 1, minor: 1, patch: 1),
1550-
generator: "unit-test"
1551-
),
1552-
module: SymbolGraph.Module(
1553-
name: "Module",
1554-
platform: SymbolGraph.Platform(architecture: nil, vendor: nil, operatingSystem: nil)
1555-
),
1556-
symbols: symbolPaths.map {
1557-
SymbolGraph.Symbol(
1558-
identifier: SymbolGraph.Symbol.Identifier(precise: $0.joined(separator: "."), interfaceLanguage: "swift"),
1559-
names: SymbolGraph.Symbol.Names(title: "Title", navigator: nil, subHeading: nil, prose: nil), // names doesn't matter for path disambiguation
1560-
pathComponents: $0,
1561-
docComment: nil,
1562-
accessLevel: SymbolGraph.Symbol.AccessControl(rawValue: "public"),
1563-
kind: SymbolGraph.Symbol.Kind(parsedIdentifier: .class, displayName: "Kind Display Name"), // kind display names doesn't matter for path disambiguation
1564-
mixins: [:]
1565-
)
1566-
},
1567-
relationships: []
1568-
)
1569-
let exampleDocumentation = Folder(name: "MyKit.docc", content: [
1570-
try TextFile(name: "mykit.symbols.json", utf8Content: XCTUnwrap(String(data: JSONEncoder().encode(graph), encoding: .utf8))),
1571-
InfoPlist(displayName: "MyKit", identifier: "com.test.MyKit"),
1547+
let exampleDocumentation = Folder(name: "unit-test.docc", content: [
1548+
JSONFile(name: "Module.symbols.json", content: makeSymbolGraph(
1549+
moduleName: "Module",
1550+
symbols: symbolPaths.map { ($0.joined(separator: "."), .swift, $0) }
1551+
)),
15721552
])
15731553
let tempURL = try createTemporaryDirectory()
15741554
let bundleURL = try exampleDocumentation.write(inside: tempURL)
@@ -1595,6 +1575,49 @@ class PathHierarchyTests: XCTestCase {
15951575
XCTAssertEqual(paths["X.Y2.Z.W"], "/Module/X/Y2/Z/W")
15961576
}
15971577

1578+
func testMixedLanguageSymbolWithItsExtendingModule() throws {
1579+
let containerID = "some-container-symbol-id"
1580+
let memberID = "some-member-symbol-id"
1581+
1582+
let exampleDocumentation = Folder(name: "unit-test.docc", content: [
1583+
Folder(name: "clang", content: [
1584+
JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph(
1585+
moduleName: "ModuleName",
1586+
symbols: [
1587+
(containerID, .objectiveC, ["ContainerName"])
1588+
]
1589+
)),
1590+
]),
1591+
1592+
Folder(name: "swift", content: [
1593+
JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph(
1594+
moduleName: "ModuleName",
1595+
symbols: [
1596+
(containerID, .swift, ["ContainerName"])
1597+
]
1598+
)),
1599+
1600+
JSONFile(name: "[email protected]", content: makeSymbolGraph(
1601+
moduleName: "ExtendingModule",
1602+
symbols: [
1603+
(memberID, .swift, ["ContainerName", "MemberName"])
1604+
],
1605+
relationships: [
1606+
.init(source: memberID, target: containerID, kind: .memberOf, targetFallback: nil)
1607+
]
1608+
)),
1609+
])
1610+
])
1611+
1612+
let tempURL = try createTempFolder(content: [exampleDocumentation])
1613+
let (_, _, context) = try loadBundle(from: tempURL)
1614+
let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy)
1615+
1616+
let paths = tree.caseInsensitiveDisambiguatedPaths()
1617+
XCTAssertEqual(paths[containerID], "/ModuleName/ContainerName")
1618+
XCTAssertEqual(paths[memberID], "/ModuleName/ContainerName/MemberName")
1619+
}
1620+
15981621
func testMultiPlatformModuleWithExtension() throws {
15991622
let (_, context) = try testBundleAndContext(named: "MultiPlatformModuleWithExtension")
16001623
let tree = try XCTUnwrap(context.linkResolver.localResolver?.pathHierarchy)
@@ -1636,6 +1659,29 @@ class PathHierarchyTests: XCTestCase {
16361659

16371660
// MARK: Test helpers
16381661

1662+
private func makeSymbolGraph(
1663+
moduleName: String,
1664+
symbols: [(identifier: String, language: SourceLanguage, pathComponents: [String])],
1665+
relationships: [SymbolGraph.Relationship] = []
1666+
) -> SymbolGraph {
1667+
return SymbolGraph(
1668+
metadata: SymbolGraph.Metadata(formatVersion: .init(major: 0, minor: 5, patch: 3), generator: "unit-test"),
1669+
module: SymbolGraph.Module(name: moduleName, platform: .init()),
1670+
symbols: symbols.map { identifier, language, pathComponents in
1671+
SymbolGraph.Symbol(
1672+
identifier: .init(precise: identifier, interfaceLanguage: language.id),
1673+
names: .init(title: "SymbolName", navigator: nil, subHeading: nil, prose: nil), // names doesn't matter for path disambiguation
1674+
pathComponents: pathComponents,
1675+
docComment: nil,
1676+
accessLevel: .public,
1677+
kind: .init(parsedIdentifier: .class, displayName: "Kind Display Name"), // kind display names doesn't matter for path disambiguation
1678+
mixins: [:]
1679+
)
1680+
},
1681+
relationships: relationships
1682+
)
1683+
}
1684+
16391685
private func assertFindsPath(_ path: String, in tree: PathHierarchy, asSymbolID symbolID: String, file: StaticString = #file, line: UInt = #line) throws {
16401686
do {
16411687
let symbol = try tree.findSymbol(path: path)

0 commit comments

Comments
 (0)