Skip to content

Commit c141bd3

Browse files
authored
Resolve dependency symbol reference in declarations and relationships (#826)
* Resolve dependency symbol reference in declarations and relationships rdar://116085974 * Use the entities relativePresentationURL as its render reference's URL * Synchronize access to 'context.externallyResolvedLinks' while resolving links * Ensure that the temp container directory exist when merging archives * Remove test assertion that's testing an incorrect behavior
1 parent 7a5bb68 commit c141bd3

File tree

10 files changed

+266
-50
lines changed

10 files changed

+266
-50
lines changed

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1546,7 +1546,8 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
15461546
in symbols: [String: UnifiedSymbolGraph.Symbol],
15471547
relationships: [UnifiedSymbolGraph.Selector: Set<SymbolGraph.Relationship>]
15481548
) throws {
1549-
guard let symbolResolver = globalExternalSymbolResolver else {
1549+
if globalExternalSymbolResolver == nil, linkResolver.externalResolvers.isEmpty {
1550+
// Context has no mechanism for resolving external symbol links. No reason to gather any symbols to resolve.
15501551
return
15511552
}
15521553

@@ -1576,9 +1577,21 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
15761577

15771578
// TODO: When the symbol graph includes the precise identifiers for conditional availability, those symbols should also be resolved (rdar://63768609).
15781579

1580+
func resolveSymbol(symbolID: String) -> (ResolvedTopicReference, LinkResolver.ExternalEntity)? {
1581+
if let globalResult = globalExternalSymbolResolver?.symbolReferenceAndEntity(withPreciseIdentifier: symbolID) {
1582+
return globalResult
1583+
}
1584+
for externalResolver in linkResolver.externalResolvers.values {
1585+
if let result = externalResolver.symbolReferenceAndEntity(symbolID: symbolID) {
1586+
return result
1587+
}
1588+
}
1589+
return nil
1590+
}
1591+
15791592
// Resolve all the collected symbol identifiers and add them do the topic graph.
15801593
for symbolID in symbolsToResolve {
1581-
if let (reference, entity) = symbolResolver.symbolReferenceAndEntity(withPreciseIdentifier: symbolID) {
1594+
if let (reference, entity) = resolveSymbol(symbolID: symbolID) {
15821595
externalCache.add(entity, reference: reference, symbolID: symbolID)
15831596
} else {
15841597
diagnosticEngine.emit(Problem(diagnostic: Diagnostic(source: nil, severity: .warning, range: nil, identifier: "org.swift.docc.ReferenceSymbolNotFound", summary: "Symbol with identifier \(symbolID.singleQuoted) was referenced in the combined symbol graph but couldn't be found in the symbol graph or externally."), possibleSolutions: []))

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

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,9 @@ final class ExternalPathHierarchyResolver {
7878
}
7979

8080
/// Returns the external entity for a symbol's unique identifier or `nil` if that symbol isn't known in this external context.
81-
func entity(symbolID usr: String) -> LinkResolver.ExternalEntity? {
82-
// TODO: Resolve external symbols by USR (rdar://116085974) (There is nothing calling this function)
83-
// This function has an optional return value since it's not easy to check what module a symbol belongs to based on its identifier.
81+
func symbolReferenceAndEntity(symbolID usr: String) -> (ResolvedTopicReference, LinkResolver.ExternalEntity)? {
8482
guard let reference = symbols[usr] else { return nil }
85-
return entity(reference)
83+
return (reference, entity(reference))
8684
}
8785

8886
/// Returns the external entity for a reference that was successfully resolved by this external resolver.
@@ -163,14 +161,14 @@ final class ExternalPathHierarchyResolver {
163161
}
164162
}
165163

166-
convenience init(dependencyArchive: URL) throws {
164+
convenience init(dependencyArchive: URL, fileManager: FileManagerProtocol) throws {
167165
// ???: Should it be the callers responsibility to pass both these URLs?
168166
let linkHierarchyFile = dependencyArchive.appendingPathComponent("link-hierarchy.json")
169167
let entityURL = dependencyArchive.appendingPathComponent("linkable-entities.json")
170168

171169
self.init(
172-
linkInformation: try JSONDecoder().decode(SerializableLinkResolutionInformation.self, from: Data(contentsOf: linkHierarchyFile)),
173-
entityInformation: try JSONDecoder().decode([LinkDestinationSummary].self, from: Data(contentsOf: entityURL))
170+
linkInformation: try JSONDecoder().decode(SerializableLinkResolutionInformation.self, from: fileManager.contents(of: linkHierarchyFile)),
171+
entityInformation: try JSONDecoder().decode([LinkDestinationSummary].self, from: fileManager.contents(of: entityURL))
174172
)
175173
}
176174
}
@@ -209,7 +207,7 @@ private extension LinkDestinationSummary {
209207
identifier: .init(referenceURL.absoluteString),
210208
titleVariants: titleVariants,
211209
abstractVariants: abstractVariants,
212-
url: referenceURL.absoluteString,
210+
url: relativePresentationURL.absoluteString,
213211
kind: kind,
214212
required: false,
215213
role: role,

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

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public class LinkResolver {
1717
@_spi(ExternalLinks) // This needs to be public SPI so that the ConvertAction can set it.
1818
public var dependencyArchives: [URL] = []
1919

20+
var fileManager: FileManagerProtocol = FileManager.default
2021
/// The link resolver to use to resolve links in the local bundle
2122
var localResolver: PathHierarchyBasedLinkResolver!
2223
/// A fallback resolver to use when the local resolver fails to resolve a link.
@@ -29,7 +30,7 @@ public class LinkResolver {
2930
/// Create link resolvers for all documentation archive dependencies.
3031
func loadExternalResolvers() throws {
3132
let resolvers = try dependencyArchives.compactMap {
32-
try ExternalPathHierarchyResolver(dependencyArchive: $0)
33+
try ExternalPathHierarchyResolver(dependencyArchive: $0, fileManager: fileManager)
3334
}
3435
for resolver in resolvers {
3536
for moduleNode in resolver.pathHierarchy.modules {
@@ -84,22 +85,16 @@ public class LinkResolver {
8485
/// - context: The documentation context to resolve the link in.
8586
/// - Returns: The result of resolving the reference.
8687
func resolve(_ unresolvedReference: UnresolvedTopicReference, in parent: ResolvedTopicReference, fromSymbolLink isCurrentlyResolvingSymbolLink: Bool, context: DocumentationContext) -> TopicReferenceResolutionResult {
87-
// Check if the unresolved reference is external
88-
if let bundleID = unresolvedReference.bundleIdentifier,
89-
!context.registeredBundles.contains(where: { bundle in
90-
bundle.identifier == bundleID || urlReadablePath(bundle.displayName) == bundleID
91-
}) {
92-
if context.externalDocumentationSources[bundleID] != nil,
93-
let resolvedExternalReference = context.externallyResolvedLinks[unresolvedReference.topicURL] {
94-
// Return the successful or failed externally resolved reference.
95-
return resolvedExternalReference
96-
} else if !context.registeredBundles.contains(where: { $0.identifier == bundleID }) {
97-
return .failure(unresolvedReference, TopicReferenceResolutionErrorInfo("No external resolver registered for \(bundleID.singleQuoted)."))
98-
}
88+
// Check if this is an external link that has previously been resolved, either successfully or not.
89+
if let previousExternalResult = contextExternalLinksLock.sync({ context.externallyResolvedLinks[unresolvedReference.topicURL] }) {
90+
return previousExternalResult
9991
}
10092

101-
if let previousExternalResult = context.externallyResolvedLinks[unresolvedReference.topicURL] {
102-
return previousExternalResult
93+
// Check if this is a link to an external documentation source that should have previously been resolved in `DocumentationContext.preResolveExternalLinks(...)`
94+
if let bundleID = unresolvedReference.bundleIdentifier,
95+
!context.registeredBundles.contains(where: { $0.identifier == bundleID || urlReadablePath($0.displayName) == bundleID })
96+
{
97+
return .failure(unresolvedReference, TopicReferenceResolutionErrorInfo("No external resolver registered for \(bundleID.singleQuoted)."))
10398
}
10499

105100
do {
@@ -108,9 +103,11 @@ public class LinkResolver {
108103
// Check if there's a known external resolver for this module.
109104
if case .moduleNotFound(_, let remainingPathComponents, _) = error, let resolver = externalResolvers[remainingPathComponents.first!.full] {
110105
let result = resolver.resolve(unresolvedReference, fromSymbolLink: isCurrentlyResolvingSymbolLink)
111-
context.externallyResolvedLinks[unresolvedReference.topicURL] = result
112-
if case .success(let resolved) = result {
113-
context.externalCache[resolved] = resolver.entity(resolved)
106+
contextExternalLinksLock.sync {
107+
context.externallyResolvedLinks[unresolvedReference.topicURL] = result
108+
if case .success(let resolved) = result {
109+
context.externalCache[resolved] = resolver.entity(resolved)
110+
}
114111
}
115112
return result
116113
}
@@ -125,6 +122,15 @@ public class LinkResolver {
125122
fatalError("Only SymbolPathTree.Error errors are raised from the symbol link resolution code above.")
126123
}
127124
}
125+
126+
/// The context resolves links concurrently for different pages.
127+
///
128+
/// Since the link resolver may both read and write to the context to cache externally resolved references, it needs to synchronize those accesses to avoid data races.
129+
///
130+
/// > Note:
131+
/// > We don't generally want to require a lock around these properties because the context will also render pages in parallel and during rendering the external content
132+
/// > is only read, so requiring synchronization would add unnecessary overhead during rendering.
133+
private var contextExternalLinksLock = Lock()
128134
}
129135

130136
// MARK: Fallback resolver

Sources/SwiftDocC/Utility/FileManagerProtocol.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ public protocol FileManagerProtocol {
6868
/// - Throws: If the file couldn't be created with the specified contents.
6969
func createFile(at: URL, contents: Data) throws
7070

71+
/// Returns the data content of a file at the given URL.
72+
///
73+
/// - Parameters:
74+
/// - url: The location to create the file
75+
///
76+
/// - Note: This method doesn't exist on ``FileManager``.
77+
/// There is a similar looking method but it doesn't provide information about potential errors.
78+
///
79+
/// - Throws: If the file couldn't be read.
80+
func contents(of url: URL) throws -> Data
81+
7182
/// Creates a file with the given contents at the given url with the specified
7283
/// writing options.
7384
///
@@ -93,6 +104,10 @@ extension FileManagerProtocol {
93104
/// most of the methods are already implemented in Foundation.
94105
@_spi(FileManagerProtocol)
95106
extension FileManager: FileManagerProtocol {
107+
// This method doesn't exist on `FileManager`. There is a similar looking method but it doesn't provide information about potential errors.
108+
public func contents(of url: URL) throws -> Data {
109+
return try Data(contentsOf: url)
110+
}
96111

97112
// This method doesn't exist on `FileManager`. There is a similar looking method but it doesn't provide information about potential errors.
98113
public func createFile(at location: URL, contents: Data) throws {

Sources/SwiftDocCTestUtilities/FilesAndFolders.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ public struct InfoPlist: File, DataRepresentable {
9393
/// The information that the Into.plist file contains.
9494
public let content: Content
9595

96-
public init(displayName: String, identifier: String? = nil, versionString: String = "1.0") {
96+
public init(displayName: String? = nil, identifier: String? = nil, versionString: String = "1.0") {
9797
self.content = Content(
9898
displayName: displayName,
9999
identifier: identifier,
@@ -102,11 +102,11 @@ public struct InfoPlist: File, DataRepresentable {
102102
}
103103

104104
public struct Content: Codable, Equatable {
105-
public let displayName: String
105+
public let displayName: String?
106106
public let identifier: String?
107107
public let versionString: String?
108108

109-
fileprivate init(displayName: String, identifier: String?, versionString: String) {
109+
fileprivate init(displayName: String?, identifier: String?, versionString: String) {
110110
self.displayName = displayName
111111
self.identifier = identifier
112112
self.versionString = versionString

Sources/SwiftDocCTestUtilities/TestFileSystem.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,15 @@ public class TestFileSystem: FileManagerProtocol, DocumentationWorkspaceDataProv
121121
defer { filesLock.unlock() }
122122

123123
guard let file = files[url.path] else {
124-
throw Errors.invalidPath(url.path)
124+
throw CocoaError.error(.fileReadNoSuchFile)
125125
}
126126
return file
127127
}
128128

129+
public func contents(of url: URL) throws -> Data {
130+
try contentsOfURL(url)
131+
}
132+
129133
func filesIn(folder: Folder, at: URL) throws -> [String: Data] {
130134
filesLock.lock()
131135
defer { filesLock.unlock() }
@@ -223,7 +227,7 @@ public class TestFileSystem: FileManagerProtocol, DocumentationWorkspaceDataProv
223227
// If it's not the root folder, check if parents exist
224228
if createIntermediates == false {
225229
guard files.keys.contains(parent.path) else {
226-
throw Errors.invalidPath(path)
230+
throw CocoaError.error(.fileReadNoSuchFile)
227231
}
228232
} else {
229233
// Create missing parent directories

Sources/SwiftDocCUtilities/Action/Actions/Action+MoveOutput.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ extension Action {
2424

2525
if let template = template {
2626
// If a template directory has been provided, create the temporary build folder with its contents
27+
// Ensure that the container exists
28+
try? fileManager.createDirectory(at: targetURL.deletingLastPathComponent(), withIntermediateDirectories: false, attributes: nil)
2729
try fileManager.copyItem(at: template, to: targetURL)
2830
} else {
2931
// Otherwise, create an empty directory

0 commit comments

Comments
 (0)