Skip to content

Commit ecca65a

Browse files
authored
Use extensionTo and declaredIn relationships to add extensions to path hierarchy (#720)
1 parent ff057d2 commit ecca65a

File tree

6 files changed

+540
-14
lines changed

6 files changed

+540
-14
lines changed

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

+51-11
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,27 @@ extension PathHierarchy {
9696
if modules.count == 1 {
9797
do {
9898
return try searchForNode(descendingFrom: modules.first!.value, pathComponents: remaining, parsedPathForError: parsedPathForError, onlyFindSymbols: onlyFindSymbols)
99-
} catch {
100-
// Ignore this error and raise an error about not finding the module instead.
99+
} catch let error as PathHierarchy.Error {
100+
switch error {
101+
case .notFound:
102+
// Ignore this error and raise an error about not finding the module instead.
103+
break
104+
case .unknownName(let partialResult, remaining: _, availableChildren: _):
105+
if partialResult.node.symbol?.kind.identifier == .module {
106+
// Failed to find the first path component. Ignore this error and raise an error about not finding the module instead.
107+
break
108+
} else {
109+
// Partially resolved the link. Raise the more specific error instead of a module-not-found error.
110+
throw error
111+
}
112+
113+
// These errors are all more specific than a module-not-found error would be.
114+
case .unfindableMatch,
115+
.nonSymbolMatchForSymbolLink,
116+
.unknownDisambiguation,
117+
.lookupCollision:
118+
throw error
119+
}
101120
}
102121
}
103122
let topLevelNames = Set(modules.keys + [articlesContainer.name, tutorialContainer.name])
@@ -193,7 +212,8 @@ extension PathHierarchy {
193212
try handleCollision(node: node, parsedPath: parsedPathForError, remaining: remaining, collisions: collisions, onlyFindSymbols: onlyFindSymbols)
194213
}
195214

196-
// See if the collision can be resolved by looking ahead on level deeper.
215+
// When there's a collision, use the remaining path components to try and narrow down the possible collisions.
216+
197217
guard let nextPathComponent = remaining.dropFirst().first else {
198218
// This was the last path component so there's nothing to look ahead.
199219
//
@@ -219,18 +239,38 @@ extension PathHierarchy {
219239
// A wrapped error would have been raised while iterating over the collection.
220240
return uniqueCollisions.first!.value
221241
}
222-
// Try resolving the rest of the path for each collision ...
223-
let possibleMatches = collisions.compactMap {
242+
243+
// Look ahead one path component to narrow down the list of collisions.
244+
// For each collision where the next path component can be found unambiguously, return that matching node one level down.
245+
let possibleMatchesOneLevelDown = collisions.compactMap {
224246
return try? $0.node.children[nextPathComponent.name]?.find(nextPathComponent.kind, nextPathComponent.hash)
225247
}
226-
// If only one collision matches, return that match.
227-
if possibleMatches.count == 1 {
228-
return possibleMatches.first!
248+
let onlyPossibleMatch: Node?
249+
250+
if possibleMatchesOneLevelDown.count == 1 {
251+
// Only one of the collisions found a match for the next path component
252+
onlyPossibleMatch = possibleMatchesOneLevelDown.first!
253+
} else if !possibleMatchesOneLevelDown.isEmpty, possibleMatchesOneLevelDown.dropFirst().allSatisfy({ $0.symbol?.identifier.precise == possibleMatchesOneLevelDown.first!.symbol?.identifier.precise }) {
254+
// It's also possible that different language representations of the same symbols appear as different collisions.
255+
// If _all_ collisions that can find the next path component are the same symbol, then we prefer the Swift version of that symbol.
256+
onlyPossibleMatch = possibleMatchesOneLevelDown.first(where: { $0.symbol?.identifier.interfaceLanguage == "swift" }) ?? possibleMatchesOneLevelDown.first!
257+
} else {
258+
onlyPossibleMatch = nil
229259
}
230-
// If all matches are the same symbol, return the Swift version of that symbol
231-
if !possibleMatches.isEmpty, possibleMatches.dropFirst().allSatisfy({ $0.symbol?.identifier.precise == possibleMatches.first!.symbol?.identifier.precise }) {
232-
return possibleMatches.first(where: { $0.symbol?.identifier.interfaceLanguage == "swift" }) ?? possibleMatches.first!
260+
261+
if let onlyPossibleMatch = onlyPossibleMatch {
262+
// If we found only a single match one level down then we've processed both this path component and the next.
263+
remaining = remaining.dropFirst(2)
264+
if remaining.isEmpty {
265+
// If that was the end of the path we can simply return the result.
266+
return onlyPossibleMatch
267+
} else {
268+
// Otherwise we continue looping over the remaining path components.
269+
node = onlyPossibleMatch
270+
continue
271+
}
233272
}
273+
234274
// Couldn't resolve the collision by look ahead.
235275
return try handleCollision(node: node, parsedPath: parsedPathForError, remaining: remaining, collisions: collisions, onlyFindSymbols: onlyFindSymbols)
236276
}

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

+13-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,19 @@ import SymbolKit
1313
/// All known symbol kind identifiers.
1414
///
1515
/// This is used to identify parsed path components as kind information.
16-
private let knownSymbolKinds = Set(SymbolGraph.Symbol.KindIdentifier.allCases.map { $0.identifier })
16+
private let knownSymbolKinds: Set<String> = {
17+
// There's nowhere else that registers these extended symbol kinds and we need to know them in this list.
18+
SymbolGraph.Symbol.KindIdentifier.register(
19+
.extendedProtocol,
20+
.extendedStructure,
21+
.extendedClass,
22+
.extendedEnumeration,
23+
.unknownExtendedType,
24+
.extendedModule
25+
)
26+
return Set(SymbolGraph.Symbol.KindIdentifier.allCases.map(\.identifier))
27+
}()
28+
1729
/// All known source language identifiers.
1830
///
1931
/// This is used to skip language prefixes from kind disambiguation information.

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

+22-2
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ struct PathHierarchy {
115115
}
116116

117117
var topLevelCandidates = nodes
118-
for relationship in graph.relationships where [.memberOf, .requirementOf, .optionalRequirementOf].contains(relationship.kind) {
118+
for relationship in graph.relationships where relationship.kind.formsHierarchy {
119119
guard let sourceNode = nodes[relationship.source] else {
120120
continue
121121
}
@@ -145,6 +145,11 @@ struct PathHierarchy {
145145
// Disfavor the default implementation to favor the protocol requirement (or other symbol with the same path).
146146
sourceNode.isDisfavoredInCollision = true
147147

148+
guard sourceNode.parent == nil else {
149+
// This node already has a direct member-of parent. No need to go via the default-implementation-of relationship to find its location in the hierarchy.
150+
continue
151+
}
152+
148153
let targetNodes = nodes[relationship.target].map { [$0] } ?? allNodes[relationship.target] ?? []
149154
guard !targetNodes.isEmpty else {
150155
continue
@@ -204,7 +209,10 @@ struct PathHierarchy {
204209

205210
var lookup = [ResolvedIdentifier: Node]()
206211
func descend(_ node: Node) {
207-
assert(node.identifier == nil)
212+
assert(
213+
node.identifier == nil,
214+
"Already encountered \(node.name). This is an indication that a symbol is the source of more than one memberOf relationship."
215+
)
208216
if node.symbol != nil {
209217
node.identifier = ResolvedIdentifier()
210218
lookup[node.identifier] = node
@@ -460,3 +468,15 @@ extension PathHierarchy.DisambiguationContainer {
460468
}))
461469
}
462470
}
471+
472+
private extension SymbolGraph.Relationship.Kind {
473+
/// Whether or not this relationship kind forms a hierarchical relationship between the source and the target.
474+
var formsHierarchy: Bool {
475+
switch self {
476+
case .memberOf, .requirementOf, .optionalRequirementOf, .extensionTo, .declaredIn:
477+
return true
478+
default:
479+
return false
480+
}
481+
}
482+
}

Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift

+43
Original file line numberDiff line numberDiff line change
@@ -1225,6 +1225,49 @@ class PathHierarchyTests: XCTestCase {
12251225
XCTAssertEqual(try tree.findSymbol(path: "Something/second", parent: topLevelSymbolID).identifier.precise, "s:9SomethingAAV6secondSivp")
12261226
}
12271227

1228+
func testSymbolsWithSameNameAsExtendedModule() throws {
1229+
// ---- Inner
1230+
// public struct InnerStruct {}
1231+
// public class InnerClass {}
1232+
//
1233+
// ---- Outer
1234+
// // Shadow the Inner module with a local type
1235+
// public struct Inner {}
1236+
//
1237+
// public extension InnerStruct {
1238+
// func something() {}
1239+
// }
1240+
// public extension InnerClass {
1241+
// func something() {}
1242+
// }
1243+
let (_, context) = try testBundleAndContext(named: "ShadowExtendedModuleWithLocalSymbol")
1244+
let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy)
1245+
1246+
try assertPathCollision("Outer/Inner", in: tree, collisions: [
1247+
("s:m:s:e:s:5Inner0A5ClassC5OuterE9somethingyyF", "module.extension"),
1248+
("s:5Outer5InnerV", "struct"),
1249+
])
1250+
// If the first path component is ambiguous, it should have the same error as if that was a later path component.
1251+
try assertPathCollision("Inner", in: tree, collisions: [
1252+
("s:m:s:e:s:5Inner0A5ClassC5OuterE9somethingyyF", "module.extension"),
1253+
("s:5Outer5InnerV", "struct"),
1254+
])
1255+
1256+
try assertFindsPath("Inner-struct", in: tree, asSymbolID: "s:5Outer5InnerV")
1257+
try assertFindsPath("Inner-module.extension", in: tree, asSymbolID: "s:m:s:e:s:5Inner0A5ClassC5OuterE9somethingyyF")
1258+
1259+
try assertFindsPath("Inner-module.extension/InnerStruct", in: tree, asSymbolID: "s:e:s:5Inner0A6StructV5OuterE9somethingyyF")
1260+
try assertFindsPath("Inner-module.extension/InnerClass", in: tree, asSymbolID: "s:e:s:5Inner0A5ClassC5OuterE9somethingyyF")
1261+
try assertFindsPath("Inner-module.extension/InnerStruct/something()", in: tree, asSymbolID: "s:5Inner0A6StructV5OuterE9somethingyyF")
1262+
try assertFindsPath("Inner-module.extension/InnerClass/something()", in: tree, asSymbolID: "s:5Inner0A5ClassC5OuterE9somethingyyF")
1263+
1264+
// The "Inner" struct doesn't have "InnerStruct" or "InnerClass" descendants so the path is not ambiguous.
1265+
try assertFindsPath("Inner/InnerStruct", in: tree, asSymbolID: "s:e:s:5Inner0A6StructV5OuterE9somethingyyF")
1266+
try assertFindsPath("Inner/InnerClass", in: tree, asSymbolID: "s:e:s:5Inner0A5ClassC5OuterE9somethingyyF")
1267+
try assertFindsPath("Inner/InnerStruct/something()", in: tree, asSymbolID: "s:5Inner0A6StructV5OuterE9somethingyyF")
1268+
try assertFindsPath("Inner/InnerClass/something()", in: tree, asSymbolID: "s:5Inner0A5ClassC5OuterE9somethingyyF")
1269+
}
1270+
12281271
func testSnippets() throws {
12291272
let (_, context) = try testBundleAndContext(named: "Snippets")
12301273
let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
{
2+
"metadata": {
3+
"formatVersion": {
4+
"major": 0,
5+
"minor": 6,
6+
"patch": 0
7+
},
8+
"generator": "Apple Swift version 5.9 (swiftlang-5.9.0.128.108 clang-1500.0.40.1)"
9+
},
10+
"module": {
11+
"name": "Outer",
12+
"platform": {
13+
"architecture": "arm64",
14+
"operatingSystem": {
15+
"minimumVersion": {
16+
"major": 14,
17+
"minor": 0
18+
},
19+
"name": "macosx"
20+
},
21+
"vendor": "apple"
22+
}
23+
},
24+
"relationships": [],
25+
"symbols": [
26+
{
27+
"accessLevel": "public",
28+
"declarationFragments": [
29+
{
30+
"kind": "keyword",
31+
"spelling": "struct"
32+
},
33+
{
34+
"kind": "text",
35+
"spelling": " "
36+
},
37+
{
38+
"kind": "identifier",
39+
"spelling": "Inner"
40+
}
41+
],
42+
"identifier": {
43+
"interfaceLanguage": "swift",
44+
"precise": "s:5Outer5InnerV"
45+
},
46+
"kind": {
47+
"displayName": "Structure",
48+
"identifier": "swift.struct"
49+
},
50+
"location": {
51+
"position": {
52+
"character": 14,
53+
"line": 11
54+
},
55+
"uri": "file:///Users/username/path/to/ShadowExtendedModuleWithLocalSymbol/Outer.swift"
56+
},
57+
"names": {
58+
"navigator": [
59+
{
60+
"kind": "identifier",
61+
"spelling": "Inner"
62+
}
63+
],
64+
"subHeading": [
65+
{
66+
"kind": "keyword",
67+
"spelling": "struct"
68+
},
69+
{
70+
"kind": "text",
71+
"spelling": " "
72+
},
73+
{
74+
"kind": "identifier",
75+
"spelling": "Inner"
76+
}
77+
],
78+
"title": "Inner"
79+
},
80+
"pathComponents": [
81+
"Inner"
82+
]
83+
}
84+
]
85+
}

0 commit comments

Comments
 (0)