Skip to content

Commit efcc793

Browse files
authored
Fix bugs with symbol links to /(_:_:) operators (#717)
* Support escaping forward slash in symbol links rdar://112555102 * Support operators with "/" without escaping rdar://112555102 * Remove need to pass original link string to create error info * Replace "/" in symbol names with "_" in resolved topic references
1 parent 00742d8 commit efcc793

File tree

10 files changed

+1094
-176
lines changed

10 files changed

+1094
-176
lines changed

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

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,7 @@ final class ExternalPathHierarchyResolver {
4545

4646
return .success(foundReference)
4747
} catch let error as PathHierarchy.Error {
48-
var originalReferenceString = unresolvedReference.path
49-
if let fragment = unresolvedReference.topicURL.components.fragment {
50-
originalReferenceString += "#" + fragment
51-
}
52-
53-
return .failure(unresolvedReference, error.asTopicReferenceResolutionErrorInfo(originalReference: originalReferenceString) { collidingNode in
48+
return .failure(unresolvedReference, error.makeTopicReferenceResolutionErrorInfo() { collidingNode in
5449
self.fullName(of: collidingNode) // If the link was ambiguous, determine the full name of each colliding node to be presented in the link diagnostic.
5550
})
5651
} catch {

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ public class LinkResolver {
7373
return try localResolver.resolve(unresolvedReference, in: parent, fromSymbolLink: isCurrentlyResolvingSymbolLink, context: context)
7474
} catch let error as PathHierarchy.Error {
7575
// Check if there's a known external resolver for this module.
76-
if case .moduleNotFound(let remainingPathComponents, _) = error, let resolver = externalResolvers[remainingPathComponents.first!.full] {
76+
if case .moduleNotFound(_, let remainingPathComponents, _) = error, let resolver = externalResolvers[remainingPathComponents.first!.full] {
7777
let result = resolver.resolve(unresolvedReference, fromSymbolLink: isCurrentlyResolvingSymbolLink)
7878
context.externallyResolvedLinks[unresolvedReference.topicURL] = result
7979
if case .success(let resolved) = result {
@@ -87,12 +87,7 @@ public class LinkResolver {
8787
if let resolvedFallbackReference = fallbackResolver.resolve(unresolvedReference, in: parent, fromSymbolLink: isCurrentlyResolvingSymbolLink, context: context) {
8888
return .success(resolvedFallbackReference)
8989
} else {
90-
var originalReferenceString = unresolvedReference.path
91-
if let fragment = unresolvedReference.topicURL.components.fragment {
92-
originalReferenceString += "#" + fragment
93-
}
94-
95-
return .failure(unresolvedReference, error.asTopicReferenceResolutionErrorInfo(originalReference: originalReferenceString) { localResolver.fullName(of: $0, in: context) })
90+
return .failure(unresolvedReference, error.makeTopicReferenceResolutionErrorInfo() { localResolver.fullName(of: $0, in: context) })
9691
}
9792
} catch {
9893
fatalError("Only SymbolPathTree.Error errors are raised from the symbol link resolution code above.")

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import Foundation
1212

13-
private let nonAllowedPathCharacters = CharacterSet.urlPathAllowed.inverted
13+
private let nonAllowedPathCharacters = CharacterSet.urlPathAllowed.inverted.union(["/"])
1414

1515
private func symbolFileName(_ symbolName: String) -> String {
1616
return symbolName.components(separatedBy: nonAllowedPathCharacters).joined(separator: "_")

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

Lines changed: 39 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -19,30 +19,34 @@ extension PathHierarchy {
1919
///
2020
/// Includes information about:
2121
/// - The node that was found
22-
/// - The remaining portion of the path.
23-
typealias PartialResult = (node: Node, path: [PathComponent])
22+
/// - The portion of the path up and including to the found node and its trailing path separator.
23+
typealias PartialResult = (node: Node, pathPrefix: Substring)
2424

2525
/// No element was found at the beginning of the path.
2626
///
2727
/// Includes information about:
28+
/// - The portion of the path up to the first path component.
2829
/// - The remaining portion of the path. This may be empty
2930
/// - A list of the names for the top level elements.
30-
case notFound(remaining: [PathComponent], availableChildren: Set<String>)
31+
case notFound(pathPrefix: Substring, remaining: [PathComponent], availableChildren: Set<String>)
3132

3233
/// No element was found at the beginning of an absolute path.
3334
///
3435
/// Includes information about:
35-
/// - The remaining portion of the path.
36+
/// - The portion of the path up to the first path component.
3637
/// - A list of the names for the available modules.
37-
case moduleNotFound(remaining: [PathComponent], availableChildren: Set<String>)
38+
case moduleNotFound(pathPrefix: Substring, remaining: [PathComponent], availableChildren: Set<String>)
3839

3940
/// Matched node does not correspond to a documentation page.
4041
///
4142
/// For partial symbol graph files, sometimes sparse nodes that don't correspond to known documentation need to be created to form a hierarchy. These nodes are not findable.
4243
case unfindableMatch(Node)
4344

4445
/// A symbol link found a non-symbol match.
45-
case nonSymbolMatchForSymbolLink
46+
///
47+
/// Includes information about:
48+
/// - The path to the non-symbol match.
49+
case nonSymbolMatchForSymbolLink(path: Substring)
4650

4751
/// Encountered an unknown disambiguation for a found node.
4852
///
@@ -71,40 +75,27 @@ extension PathHierarchy {
7175
}
7276

7377
extension PathHierarchy.Error {
74-
/// Generate a ``TopicReferenceResolutionError`` from this error using the given `context` and `originalReference`.
75-
///
76-
/// The resulting ``TopicReferenceResolutionError`` is human-readable and provides helpful solutions.
77-
///
78+
/// Creates a value with structured information that can be used to present diagnostics about the error.
7879
/// - Parameters:
79-
/// - originalReference: The raw input string that represents the body of the reference that failed to resolve. This string is used to calculate the proper replacement-ranges for fixits.
8080
/// - fullNameOfNode: A closure that determines the full name of a node, to be displayed in collision diagnostics to precisely identify symbols and other pages.
81-
///
82-
/// - Note: `Replacement`s produced by this function use `SourceLocation`s relative to the `originalReference`, i.e. the beginning
83-
/// of the _body_ of the original reference.
84-
func asTopicReferenceResolutionErrorInfo(originalReference: String, fullNameOfNode: (PathHierarchy.Node) -> String) -> TopicReferenceResolutionErrorInfo {
81+
/// - Note: `Replacement`s produced by this function use `SourceLocation`s relative to the link text excluding its surrounding syntax.
82+
func makeTopicReferenceResolutionErrorInfo(fullNameOfNode: (PathHierarchy.Node) -> String) -> TopicReferenceResolutionErrorInfo {
8583
// This is defined inline because it captures `fullNameOfNode`.
8684
func collisionIsBefore(_ lhs: (node: PathHierarchy.Node, disambiguation: String), _ rhs: (node: PathHierarchy.Node, disambiguation: String)) -> Bool {
8785
return fullNameOfNode(lhs.node) + lhs.disambiguation
8886
< fullNameOfNode(rhs.node) + rhs.disambiguation
8987
}
9088

9189
switch self {
92-
case .moduleNotFound(remaining: let remaining, availableChildren: let availableChildren):
90+
case .moduleNotFound(pathPrefix: let pathPrefix, remaining: let remaining, availableChildren: let availableChildren):
9391
let firstPathComponent = remaining.first! // This would be a .notFound error if the remaining components were empty.
9492

95-
let solutions: [Solution]
96-
if let pathComponentIndex = originalReference.range(of: firstPathComponent.full) {
97-
let startColumn = originalReference.distance(from: originalReference.startIndex, to: pathComponentIndex.lowerBound)
98-
let replacementRange = SourceRange.makeRelativeRange(startColumn: startColumn, length: firstPathComponent.full.count)
99-
100-
let nearMisses = NearMiss.bestMatches(for: availableChildren, against: firstPathComponent.name)
101-
solutions = nearMisses.map { candidate in
102-
Solution(summary: "\(Self.replacementOperationDescription(from: firstPathComponent.full, to: candidate))", replacements: [
103-
Replacement(range: replacementRange, replacement: candidate)
104-
])
105-
}
106-
} else {
107-
solutions = []
93+
let replacementRange = SourceRange.makeRelativeRange(startColumn: pathPrefix.count, length: firstPathComponent.full.count)
94+
let nearMisses = NearMiss.bestMatches(for: availableChildren, against: String(firstPathComponent.name))
95+
let solutions = nearMisses.map { candidate in
96+
Solution(summary: "\(Self.replacementOperationDescription(from: firstPathComponent.full, to: candidate))", replacements: [
97+
Replacement(range: replacementRange, replacement: candidate)
98+
])
10899
}
109100

110101
return TopicReferenceResolutionErrorInfo("""
@@ -113,26 +104,19 @@ extension PathHierarchy.Error {
113104
solutions: solutions
114105
)
115106

116-
case .notFound(remaining: let remaining, availableChildren: let availableChildren):
107+
case .notFound(pathPrefix: let pathPrefix, remaining: let remaining, availableChildren: let availableChildren):
117108
guard let firstPathComponent = remaining.first else {
118109
return TopicReferenceResolutionErrorInfo(
119110
"No local documentation matches this reference"
120111
)
121112
}
122113

123-
let solutions: [Solution]
124-
if let pathComponentIndex = originalReference.range(of: firstPathComponent.full) {
125-
let startColumn = originalReference.distance(from: originalReference.startIndex, to: pathComponentIndex.lowerBound)
126-
let replacementRange = SourceRange.makeRelativeRange(startColumn: startColumn, length: firstPathComponent.full.count)
127-
128-
let nearMisses = NearMiss.bestMatches(for: availableChildren, against: firstPathComponent.name)
129-
solutions = nearMisses.map { candidate in
130-
Solution(summary: "\(Self.replacementOperationDescription(from: firstPathComponent.full, to: candidate))", replacements: [
131-
Replacement(range: replacementRange, replacement: candidate)
132-
])
133-
}
134-
} else {
135-
solutions = []
114+
let replacementRange = SourceRange.makeRelativeRange(startColumn: pathPrefix.count, length: firstPathComponent.full.count)
115+
let nearMisses = NearMiss.bestMatches(for: availableChildren, against: String(firstPathComponent.name))
116+
let solutions = nearMisses.map { candidate in
117+
Solution(summary: "\(Self.replacementOperationDescription(from: firstPathComponent.full, to: candidate))", replacements: [
118+
Replacement(range: replacementRange, replacement: candidate)
119+
])
136120
}
137121

138122
return TopicReferenceResolutionErrorInfo("""
@@ -146,23 +130,19 @@ extension PathHierarchy.Error {
146130
\(node.name.singleQuoted) can't be linked to in a partial documentation build
147131
""")
148132

149-
case .nonSymbolMatchForSymbolLink:
133+
case .nonSymbolMatchForSymbolLink(path: let path):
150134
return TopicReferenceResolutionErrorInfo("Symbol links can only resolve symbols", solutions: [
151135
Solution(summary: "Use a '<doc:>' style reference.", replacements: [
152136
// the SourceRange points to the opening double-backtick
153137
Replacement(range: .makeRelativeRange(startColumn: -2, endColumn: 0), replacement: "<doc:"),
154138
// the SourceRange points to the closing double-backtick
155-
Replacement(range: .makeRelativeRange(startColumn: originalReference.count, endColumn: originalReference.count+2), replacement: ">"),
139+
Replacement(range: .makeRelativeRange(startColumn: path.count, endColumn: path.count+2), replacement: ">"),
156140
])
157141
])
158142

159143
case .unknownDisambiguation(partialResult: let partialResult, remaining: let remaining, candidates: let candidates):
160144
let nextPathComponent = remaining.first!
161-
var validPrefix = ""
162-
if !partialResult.path.isEmpty {
163-
validPrefix += PathHierarchy.joined(partialResult.path) + "/"
164-
}
165-
validPrefix += nextPathComponent.name
145+
let validPrefix = partialResult.pathPrefix + nextPathComponent.name
166146

167147
let disambiguations = nextPathComponent.full.dropFirst(nextPathComponent.name.count)
168148
let replacementRange = SourceRange.makeRelativeRange(startColumn: validPrefix.count, length: disambiguations.count)
@@ -184,30 +164,27 @@ extension PathHierarchy.Error {
184164

185165
case .unknownName(partialResult: let partialResult, remaining: let remaining, availableChildren: let availableChildren):
186166
let nextPathComponent = remaining.first!
187-
let nearMisses = NearMiss.bestMatches(for: availableChildren, against: nextPathComponent.name)
167+
let nearMisses = NearMiss.bestMatches(for: availableChildren, against: String(nextPathComponent.name))
188168

189169
// Use the authored disambiguation to try and reduce the possible near misses. For example, if the link was disambiguated with `-struct` we should
190170
// only make suggestions for similarly spelled structs.
191171
let filteredNearMisses = nearMisses.filter { name in
192-
(try? partialResult.node.children[name]?.find(nextPathComponent.kind, nextPathComponent.hash)) != nil
172+
(try? partialResult.node.children[name]?.find(nextPathComponent.kind.map(String.init), nextPathComponent.hash.map(String.init))) != nil
193173
}
194174

195-
var validPrefix = ""
196-
if !partialResult.path.isEmpty {
197-
validPrefix += PathHierarchy.joined(partialResult.path) + "/"
198-
}
175+
let pathPrefix = partialResult.pathPrefix
199176
let solutions: [Solution]
200177
if filteredNearMisses.isEmpty {
201178
// If there are no near-misses where the authored disambiguation narrow down the results, replace the full path component
202-
let replacementRange = SourceRange.makeRelativeRange(startColumn: validPrefix.count, length: nextPathComponent.full.count)
179+
let replacementRange = SourceRange.makeRelativeRange(startColumn: pathPrefix.count, length: nextPathComponent.full.count)
203180
solutions = nearMisses.map { candidate in
204181
Solution(summary: "\(Self.replacementOperationDescription(from: nextPathComponent.full, to: candidate))", replacements: [
205182
Replacement(range: replacementRange, replacement: candidate)
206183
])
207184
}
208185
} else {
209186
// If the authored disambiguation narrows down the possible near-misses, only replace the name part of the path component
210-
let replacementRange = SourceRange.makeRelativeRange(startColumn: validPrefix.count, length: nextPathComponent.name.count)
187+
let replacementRange = SourceRange.makeRelativeRange(startColumn: pathPrefix.count, length: nextPathComponent.name.count)
211188
solutions = filteredNearMisses.map { candidate in
212189
Solution(summary: "\(Self.replacementOperationDescription(from: nextPathComponent.name, to: candidate))", replacements: [
213190
Replacement(range: replacementRange, replacement: candidate)
@@ -219,20 +196,16 @@ extension PathHierarchy.Error {
219196
\(nextPathComponent.full.singleQuoted) doesn't exist at \(partialResult.node.pathWithoutDisambiguation().singleQuoted)
220197
""",
221198
solutions: solutions,
222-
rangeAdjustment: .makeRelativeRange(startColumn: validPrefix.count, length: nextPathComponent.full.count)
199+
rangeAdjustment: .makeRelativeRange(startColumn: pathPrefix.count, length: nextPathComponent.full.count)
223200
)
224201

225202
case .lookupCollision(partialResult: let partialResult, remaining: let remaining, collisions: let collisions):
226203
let nextPathComponent = remaining.first!
227204

228-
var validPrefix = ""
229-
if !partialResult.path.isEmpty {
230-
validPrefix += PathHierarchy.joined(partialResult.path) + "/"
231-
}
232-
validPrefix += nextPathComponent.name
205+
let pathPrefix = partialResult.pathPrefix + nextPathComponent.name
233206

234207
let disambiguations = nextPathComponent.full.dropFirst(nextPathComponent.name.count)
235-
let replacementRange = SourceRange.makeRelativeRange(startColumn: validPrefix.count, length: disambiguations.count)
208+
let replacementRange = SourceRange.makeRelativeRange(startColumn: pathPrefix.count, length: disambiguations.count)
236209

237210
let solutions: [Solution] = collisions.sorted(by: collisionIsBefore).map { (node: PathHierarchy.Node, disambiguation: String) -> Solution in
238211
return Solution(summary: "\(Self.replacementOperationDescription(from: disambiguations.dropFirst(), to: disambiguation)) for\n\(fullNameOfNode(node).singleQuoted)", replacements: [
@@ -244,7 +217,7 @@ extension PathHierarchy.Error {
244217
\(nextPathComponent.full.singleQuoted) is ambiguous at \(partialResult.node.pathWithoutDisambiguation().singleQuoted)
245218
""",
246219
solutions: solutions,
247-
rangeAdjustment: .makeRelativeRange(startColumn: validPrefix.count - nextPathComponent.full.count, length: nextPathComponent.full.count)
220+
rangeAdjustment: .makeRelativeRange(startColumn: pathPrefix.count - nextPathComponent.full.count, length: nextPathComponent.full.count)
248221
)
249222
}
250223
}

0 commit comments

Comments
 (0)