Skip to content

Commit 483297f

Browse files
authored
Prefer non-symbols in general documentation links (#594)
* Prefer non-symbols in general documentation links rdar://109583745 * Elaborate comment about filtering symbol and non-symbol collisions. Also, add test where symbol and non-symbol have same path.
1 parent 05ea066 commit 483297f

File tree

3 files changed

+160
-21
lines changed

3 files changed

+160
-21
lines changed

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

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -361,20 +361,20 @@ struct PathHierarchy {
361361
break lookForArticleRoot
362362
}
363363
}
364-
return try searchForNode(descendingFrom: articlesContainer, pathComponents: remaining.dropFirst(), parsedPathForError: parsedPathForError)
364+
return try searchForNode(descendingFrom: articlesContainer, pathComponents: remaining.dropFirst(), parsedPathForError: parsedPathForError, onlyFindSymbols: onlyFindSymbols)
365365
} else if articlesContainer.anyChildMatches(firstComponent) {
366-
return try searchForNode(descendingFrom: articlesContainer, pathComponents: remaining, parsedPathForError: parsedPathForError)
366+
return try searchForNode(descendingFrom: articlesContainer, pathComponents: remaining, parsedPathForError: parsedPathForError, onlyFindSymbols: onlyFindSymbols)
367367
}
368368
}
369369
if !isKnownDocumentationPath {
370370
if tutorialContainer.matches(firstComponent) {
371-
return try searchForNode(descendingFrom: tutorialContainer, pathComponents: remaining.dropFirst(), parsedPathForError: parsedPathForError)
371+
return try searchForNode(descendingFrom: tutorialContainer, pathComponents: remaining.dropFirst(), parsedPathForError: parsedPathForError, onlyFindSymbols: onlyFindSymbols)
372372
} else if tutorialContainer.anyChildMatches(firstComponent) {
373-
return try searchForNode(descendingFrom: tutorialContainer, pathComponents: remaining, parsedPathForError: parsedPathForError)
373+
return try searchForNode(descendingFrom: tutorialContainer, pathComponents: remaining, parsedPathForError: parsedPathForError, onlyFindSymbols: onlyFindSymbols)
374374
}
375375
// The parent for tutorial overviews / technologies is "tutorials" which has already been removed above, so no need to check against that name.
376376
else if tutorialOverviewContainer.anyChildMatches(firstComponent) {
377-
return try searchForNode(descendingFrom: tutorialOverviewContainer, pathComponents: remaining, parsedPathForError: parsedPathForError)
377+
return try searchForNode(descendingFrom: tutorialOverviewContainer, pathComponents: remaining, parsedPathForError: parsedPathForError, onlyFindSymbols: onlyFindSymbols)
378378
}
379379
}
380380
}
@@ -383,11 +383,11 @@ struct PathHierarchy {
383383
func searchForNodeInModules() throws -> Node {
384384
// Note: This captures `parentID`, `remaining`, and `parsedPathForError`.
385385
if let moduleMatch = modules[firstComponent.full] ?? modules[firstComponent.name] {
386-
return try searchForNode(descendingFrom: moduleMatch, pathComponents: remaining.dropFirst(), parsedPathForError: parsedPathForError)
386+
return try searchForNode(descendingFrom: moduleMatch, pathComponents: remaining.dropFirst(), parsedPathForError: parsedPathForError, onlyFindSymbols: onlyFindSymbols)
387387
}
388388
if modules.count == 1 {
389389
do {
390-
return try searchForNode(descendingFrom: modules.first!.value, pathComponents: remaining, parsedPathForError: parsedPathForError)
390+
return try searchForNode(descendingFrom: modules.first!.value, pathComponents: remaining, parsedPathForError: parsedPathForError, onlyFindSymbols: onlyFindSymbols)
391391
} catch {
392392
// Ignore this error and raise an error about not finding the module instead.
393393
}
@@ -405,7 +405,7 @@ struct PathHierarchy {
405405
} catch {
406406
// If the node couldn't be found in the modules, search the non-matching parent to achieve a more specific error message
407407
if let parentID = parentID {
408-
return try searchForNode(descendingFrom: lookup[parentID]!, pathComponents: path, parsedPathForError: parsedPathForError)
408+
return try searchForNode(descendingFrom: lookup[parentID]!, pathComponents: path, parsedPathForError: parsedPathForError, onlyFindSymbols: onlyFindSymbols)
409409
}
410410
throw error
411411
}
@@ -420,15 +420,15 @@ struct PathHierarchy {
420420
// If the starting point's children match this component, descend the path hierarchy from there.
421421
if possibleStartingPoint.anyChildMatches(firstComponent) {
422422
do {
423-
return try searchForNode(descendingFrom: possibleStartingPoint, pathComponents: path, parsedPathForError: parsedPathForError)
423+
return try searchForNode(descendingFrom: possibleStartingPoint, pathComponents: path, parsedPathForError: parsedPathForError, onlyFindSymbols: onlyFindSymbols)
424424
} catch {
425425
innerMostError = error
426426
}
427427
}
428428
// It's possible that the component is ambiguous at the parent. Checking if this node matches the first component avoids that ambiguity.
429429
if possibleStartingPoint.matches(firstComponent) {
430430
do {
431-
return try searchForNode(descendingFrom: possibleStartingPoint, pathComponents: path.dropFirst(), parsedPathForError: parsedPathForError)
431+
return try searchForNode(descendingFrom: possibleStartingPoint, pathComponents: path.dropFirst(), parsedPathForError: parsedPathForError, onlyFindSymbols: onlyFindSymbols)
432432
} catch {
433433
if innerMostError == nil {
434434
innerMostError = error
@@ -453,7 +453,8 @@ struct PathHierarchy {
453453
private func searchForNode(
454454
descendingFrom startingPoint: Node,
455455
pathComponents: ArraySlice<PathComponent>,
456-
parsedPathForError: () -> [PathComponent]
456+
parsedPathForError: () -> [PathComponent],
457+
onlyFindSymbols: Bool
457458
) throws -> Node {
458459
var node = startingPoint
459460
var remaining = pathComponents[...]
@@ -481,7 +482,7 @@ struct PathHierarchy {
481482
}
482483
} catch DisambiguationTree.Error.lookupCollision(let collisions) {
483484
func handleWrappedCollision() throws -> Node {
484-
try handleCollision(node: node, parsedPath: parsedPathForError(), remaining: remaining, collisions: collisions)
485+
try handleCollision(node: node, parsedPath: parsedPathForError, remaining: remaining, collisions: collisions, onlyFindSymbols: onlyFindSymbols)
485486
}
486487

487488
// See if the collision can be resolved by looking ahead on level deeper.
@@ -523,26 +524,45 @@ struct PathHierarchy {
523524
return possibleMatches.first(where: { $0.symbol?.identifier.interfaceLanguage == "swift" }) ?? possibleMatches.first!
524525
}
525526
// Couldn't resolve the collision by look ahead.
526-
return try handleCollision(node: node, parsedPath: parsedPathForError(), remaining: remaining, collisions: collisions)
527+
return try handleCollision(node: node, parsedPath: parsedPathForError, remaining: remaining, collisions: collisions, onlyFindSymbols: onlyFindSymbols)
527528
}
528529
}
529530
}
530531

531532
private func handleCollision(
532533
node: Node,
533-
parsedPath: [PathComponent],
534+
parsedPath: () -> [PathComponent],
534535
remaining: ArraySlice<PathComponent>,
535-
collisions: [(node: PathHierarchy.Node, disambiguation: String)]
536+
collisions: [(node: PathHierarchy.Node, disambiguation: String)],
537+
onlyFindSymbols: Bool
536538
) throws -> Node {
537-
let favoredNodes = collisions.filter { $0.node.isDisfavoredInCollision == false }
538-
if favoredNodes.count == 1 {
539-
return favoredNodes.first!.node
539+
if let favoredMatch = collisions.singleMatch({ $0.node.isDisfavoredInCollision == false }) {
540+
return favoredMatch.node
541+
}
542+
// If a module has the same name as the article root (which is named after the bundle display name) then its possible
543+
// for an article a symbol to collide. Articles aren't supported in symbol links but symbols are supported in general
544+
// documentation links (although the non-symbol result is prioritized).
545+
//
546+
// There is a later check that the returned node is a symbol for symbol links, but that won't happen if the link is a
547+
// collision. To fully handle the collision in both directions, the check below uses `onlyFindSymbols` in the closure
548+
// so that only symbol matches are returned for symbol links (when `onlyFindSymbols` is `true`) and non-symbol matches
549+
// for general documentation links (when `onlyFindSymbols` is `false`).
550+
//
551+
// It's a more compact way to write
552+
//
553+
// if onlyFindSymbols {
554+
// return $0.node.symbol != nil
555+
// } else {
556+
// return $0.node.symbol == nil
557+
// }
558+
if let symbolOrNonSymbolMatch = collisions.singleMatch({ ($0.node.symbol != nil) == onlyFindSymbols }) {
559+
return symbolOrNonSymbolMatch.node
540560
}
541561

542562
throw Error.lookupCollision(
543563
partialResult: (
544564
node,
545-
Array(parsedPath.dropLast(remaining.count))
565+
Array(parsedPath().dropLast(remaining.count))
546566
),
547567
remaining: Array(remaining),
548568
collisions: collisions.map { ($0.node, $0.disambiguation) }
@@ -598,6 +618,25 @@ struct PathHierarchy {
598618
}
599619
}
600620

621+
private extension Sequence {
622+
/// Returns the only element of the sequence that satisfies the given predicate.
623+
/// - Parameters:
624+
/// - predicate: A closure that takes an element of the sequence as its argument and returns a Boolean value indicating whether the element is a match.
625+
/// - Returns: The only element of the sequence that satisfies `predicate`, or `nil` if multiple elements satisfy the predicate or if no element satisfy the predicate.
626+
/// - Complexity: O(_n_), where _n_ is the length of the sequence.
627+
func singleMatch(_ predicate: (Element) -> Bool) -> Element? {
628+
var match: Element?
629+
for element in self where predicate(element) {
630+
guard match == nil else {
631+
// Found a second match. No need to check the rest of the sequence.
632+
return nil
633+
}
634+
match = element
635+
}
636+
return match
637+
}
638+
}
639+
601640
extension PathHierarchy {
602641
/// A node in the path hierarchy.
603642
final class Node {

Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2133,6 +2133,50 @@ let expected = """
21332133
XCTAssertEqual(context.problems.filter { $0.diagnostic.source?.path.hasSuffix(newArticle1URL.lastPathComponent) == true }.count, 0)
21342134
}
21352135

2136+
func testPrefersNonSymbolsInDocLink() throws {
2137+
try XCTSkipUnless(LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver)
2138+
2139+
let (_, bundle, context) = try testBundleAndContext(copying: "SymbolsWithSameNameAsModule") { url in
2140+
// This bundle has a top-level struct named "Wrapper". Adding an article named "Wrapper.md" introduces a possibility for a link collision
2141+
try """
2142+
# An article
2143+
2144+
This is an article with the same name as a top-level symbol
2145+
""".write(to: url.appendingPathComponent("Wrapper.md"), atomically: true, encoding: .utf8)
2146+
2147+
// Also change the display name so that the article container has the same name as the module.
2148+
try InfoPlist(displayName: "Something", identifier: "com.example.Something").write(inside: url)
2149+
2150+
// Use a doc-link to curate the article.
2151+
try """
2152+
# ``Something``
2153+
2154+
Curate the article and the symbol top-level.
2155+
2156+
## Topics
2157+
2158+
- <doc:Wrapper>
2159+
""".write(to: url.appendingPathComponent("Something.md"), atomically: true, encoding: .utf8)
2160+
}
2161+
2162+
let moduleReference = try XCTUnwrap(context.rootModules.first)
2163+
let moduleNode = try context.entity(with: moduleReference)
2164+
2165+
let renderContext = RenderContext(documentationContext: context, bundle: bundle)
2166+
let converter = DocumentationContextConverter(bundle: bundle, context: context, renderContext: renderContext)
2167+
let source = context.documentURL(for: moduleReference)
2168+
2169+
let renderNode = try XCTUnwrap(converter.renderNode(for: moduleNode, at: source))
2170+
let curatedTopic = try XCTUnwrap(renderNode.topicSections.first?.identifiers.first)
2171+
2172+
let topicReference = try XCTUnwrap(renderNode.references[curatedTopic] as? TopicRenderReference)
2173+
XCTAssertEqual(topicReference.title, "An article")
2174+
2175+
// This test also reproduce https://github.com/apple/swift-docc/issues/593
2176+
// When that's fixed this test should also use a symbol link to curate the top-level symbol and verify that
2177+
// the symbol link resolves to the symbol.
2178+
}
2179+
21362180
// Modules that are being extended should not have their own symbol in the current bundle's graph.
21372181
func testNoSymbolForTertiarySymbolGraphModules() throws {
21382182
// Add an article without curating it anywhere

Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,6 +1115,24 @@ class PathHierarchyTests: XCTestCase {
11151115
"/MixedLanguageFramework/SwiftOnlyStruct/tada()")
11161116
}
11171117

1118+
func testArticleAndSymbolCollisions() throws {
1119+
try XCTSkipUnless(LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver)
1120+
let (_, _, context) = try testBundleAndContext(copying: "MixedLanguageFramework") { url in
1121+
try """
1122+
# An article
1123+
1124+
This article has the same path as a symbol
1125+
""".write(to: url.appendingPathComponent("Bar.md"), atomically: true, encoding: .utf8)
1126+
}
1127+
let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy)
1128+
1129+
// The added article above has the same path as an existing symbol in the this module.
1130+
let symbolNode = try tree.findNode(path: "/MixedLanguageFramework/Bar", onlyFindSymbols: true)
1131+
XCTAssertNotNil(symbolNode.symbol, "Symbol link finds the symbol")
1132+
let articleNode = try tree.findNode(path: "/MixedLanguageFramework/Bar", onlyFindSymbols: false)
1133+
XCTAssertNil(articleNode.symbol, "General documentation link find the article")
1134+
}
1135+
11181136
func testOverloadedSymbols() throws {
11191137
try XCTSkipUnless(LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver)
11201138
let (_, context) = try testBundleAndContext(named: "OverloadedSymbols")
@@ -1319,6 +1337,40 @@ class PathHierarchyTests: XCTestCase {
13191337
XCTAssertEqual(try tree.findSymbol(path: "Something/SomethingElse", parent: moduleID).absolutePath, "Something/SomethingElse")
13201338
}
13211339

1340+
func testPrefersNonSymbolsWhenOnlyFindSymbolIsFalse() throws {
1341+
try XCTSkipUnless(LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver)
1342+
1343+
let (_, _, context) = try testBundleAndContext(copying: "SymbolsWithSameNameAsModule") { url in
1344+
// This bundle has a top-level struct named "Wrapper". Adding an article named "Wrapper.md" introduces a possibility for a link collision
1345+
try """
1346+
# An article
1347+
1348+
This is an article with the same name as a top-level symbol
1349+
""".write(to: url.appendingPathComponent("Wrapper.md"), atomically: true, encoding: .utf8)
1350+
1351+
// Also change the display name so that the article container has the same name as the module.
1352+
try InfoPlist(displayName: "Something", identifier: "com.example.Something").write(inside: url)
1353+
}
1354+
let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy)
1355+
1356+
do {
1357+
// Links to non-symbols can use only the file name, without specifying the module or catalog name.
1358+
let articleID = try tree.find(path: "Wrapper", onlyFindSymbols: false)
1359+
let articleMatch = try XCTUnwrap(tree.lookup[articleID])
1360+
XCTAssertNil(articleMatch.symbol, "Should have found the article")
1361+
}
1362+
do {
1363+
// Links to non-symbols can also use module-relative links.
1364+
let articleID = try tree.find(path: "/Something/Wrapper", onlyFindSymbols: false)
1365+
let articleMatch = try XCTUnwrap(tree.lookup[articleID])
1366+
XCTAssertNil(articleMatch.symbol, "Should have found the article")
1367+
}
1368+
// Symbols can only use absolute links or be found relative to another page.
1369+
let symbolID = try tree.find(path: "/Something/Wrapper", onlyFindSymbols: true)
1370+
let symbolMatch = try XCTUnwrap(tree.lookup[symbolID])
1371+
XCTAssertNotNil(symbolMatch.symbol, "Should have found the struct")
1372+
}
1373+
13221374
func testOneSymbolPathsWithKnownDisambiguation() throws {
13231375
try XCTSkipUnless(LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver)
13241376
let exampleDocumentation = Folder(name: "MyKit.docc", content: [
@@ -1596,9 +1648,13 @@ class PathHierarchyTests: XCTestCase {
15961648
}
15971649

15981650
extension PathHierarchy {
1651+
func findNode(path rawPath: String, onlyFindSymbols: Bool, parent: ResolvedIdentifier? = nil) throws -> PathHierarchy.Node {
1652+
let id = try find(path: rawPath, parent: parent, onlyFindSymbols: onlyFindSymbols)
1653+
return lookup[id]!
1654+
}
1655+
15991656
func findSymbol(path rawPath: String, parent: ResolvedIdentifier? = nil) throws -> SymbolGraph.Symbol {
1600-
let id = try find(path: rawPath, parent: parent, onlyFindSymbols: true)
1601-
return lookup[id]!.symbol!
1657+
return try findNode(path: rawPath, onlyFindSymbols: true, parent: parent).symbol!
16021658
}
16031659
}
16041660

0 commit comments

Comments
 (0)