Skip to content

Commit 062fbf5

Browse files
authored
Fix bugs with symbol links to /(_:_:) operators (#717) (#772)
* 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 2219441 commit 062fbf5

File tree

9 files changed

+1105
-176
lines changed

9 files changed

+1105
-176
lines changed

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: 36 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,27 @@ 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
/// Matched node does not correspond to a documentation page.
3334
///
3435
/// 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.
3536
case unfindableMatch(Node)
3637

3738
/// A symbol link found a non-symbol match.
38-
case nonSymbolMatchForSymbolLink
39+
///
40+
/// Includes information about:
41+
/// - The path to the non-symbol match.
42+
case nonSymbolMatchForSymbolLink(path: Substring)
3943

4044
/// Encountered an unknown disambiguation for a found node.
4145
///
@@ -64,46 +68,31 @@ extension PathHierarchy {
6468
}
6569

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

8682
switch self {
87-
case .notFound(remaining: let remaining, availableChildren: let availableChildren):
83+
case .notFound(pathPrefix: let pathPrefix, remaining: let remaining, availableChildren: let availableChildren):
8884
guard let firstPathComponent = remaining.first else {
8985
return TopicReferenceResolutionErrorInfo(
9086
"No local documentation matches this reference"
9187
)
9288
}
9389

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

10998
return TopicReferenceResolutionErrorInfo("""
@@ -117,31 +106,27 @@ extension PathHierarchy.Error {
117106
\(node.name.singleQuoted) can't be linked to in a partial documentation build
118107
""")
119108

120-
case .nonSymbolMatchForSymbolLink:
109+
case .nonSymbolMatchForSymbolLink(path: let path):
121110
return TopicReferenceResolutionErrorInfo("Symbol links can only resolve symbols", solutions: [
122111
Solution(summary: "Use a '<doc:>' style reference.", replacements: [
123112
// the SourceRange points to the opening double-backtick
124113
Replacement(range: .makeRelativeRange(startColumn: -2, endColumn: 0), replacement: "<doc:"),
125114
// the SourceRange points to the closing double-backtick
126-
Replacement(range: .makeRelativeRange(startColumn: originalReference.count, endColumn: originalReference.count+2), replacement: ">"),
115+
Replacement(range: .makeRelativeRange(startColumn: path.count, endColumn: path.count+2), replacement: ">"),
127116
])
128117
])
129118

130119
case .unknownDisambiguation(partialResult: let partialResult, remaining: let remaining, candidates: let candidates):
131120
let nextPathComponent = remaining.first!
132-
var validPrefix = ""
133-
if !partialResult.path.isEmpty {
134-
validPrefix += PathHierarchy.joined(partialResult.path) + "/"
135-
}
136-
validPrefix += nextPathComponent.name
121+
let validPrefix = partialResult.pathPrefix + nextPathComponent.name
137122

138123
let disambiguations = nextPathComponent.full.dropFirst(nextPathComponent.name.count)
139124
let replacementRange = SourceRange.makeRelativeRange(startColumn: validPrefix.count, length: disambiguations.count)
140125

141126
let solutions: [Solution] = candidates
142127
.sorted(by: collisionIsBefore)
143128
.map { (node: PathHierarchy.Node, disambiguation: String) -> Solution in
144-
return Solution(summary: "\(Self.replacementOperationDescription(from: disambiguations.dropFirst(), to: disambiguation)) for\n\(node.fullNameOfValue(context: context).singleQuoted)", replacements: [
129+
return Solution(summary: "\(Self.replacementOperationDescription(from: disambiguations.dropFirst(), to: disambiguation)) for\n\(fullNameOfNode(node).singleQuoted)", replacements: [
145130
Replacement(range: replacementRange, replacement: "-" + disambiguation)
146131
])
147132
}
@@ -155,30 +140,27 @@ extension PathHierarchy.Error {
155140

156141
case .unknownName(partialResult: let partialResult, remaining: let remaining, availableChildren: let availableChildren):
157142
let nextPathComponent = remaining.first!
158-
let nearMisses = NearMiss.bestMatches(for: availableChildren, against: nextPathComponent.name)
143+
let nearMisses = NearMiss.bestMatches(for: availableChildren, against: String(nextPathComponent.name))
159144

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

166-
var validPrefix = ""
167-
if !partialResult.path.isEmpty {
168-
validPrefix += PathHierarchy.joined(partialResult.path) + "/"
169-
}
151+
let pathPrefix = partialResult.pathPrefix
170152
let solutions: [Solution]
171153
if filteredNearMisses.isEmpty {
172154
// If there are no near-misses where the authored disambiguation narrow down the results, replace the full path component
173-
let replacementRange = SourceRange.makeRelativeRange(startColumn: validPrefix.count, length: nextPathComponent.full.count)
155+
let replacementRange = SourceRange.makeRelativeRange(startColumn: pathPrefix.count, length: nextPathComponent.full.count)
174156
solutions = nearMisses.map { candidate in
175157
Solution(summary: "\(Self.replacementOperationDescription(from: nextPathComponent.full, to: candidate))", replacements: [
176158
Replacement(range: replacementRange, replacement: candidate)
177159
])
178160
}
179161
} else {
180162
// If the authored disambiguation narrows down the possible near-misses, only replace the name part of the path component
181-
let replacementRange = SourceRange.makeRelativeRange(startColumn: validPrefix.count, length: nextPathComponent.name.count)
163+
let replacementRange = SourceRange.makeRelativeRange(startColumn: pathPrefix.count, length: nextPathComponent.name.count)
182164
solutions = filteredNearMisses.map { candidate in
183165
Solution(summary: "\(Self.replacementOperationDescription(from: nextPathComponent.name, to: candidate))", replacements: [
184166
Replacement(range: replacementRange, replacement: candidate)
@@ -190,23 +172,19 @@ extension PathHierarchy.Error {
190172
\(nextPathComponent.full.singleQuoted) doesn't exist at \(partialResult.node.pathWithoutDisambiguation().singleQuoted)
191173
""",
192174
solutions: solutions,
193-
rangeAdjustment: .makeRelativeRange(startColumn: validPrefix.count, length: nextPathComponent.full.count)
175+
rangeAdjustment: .makeRelativeRange(startColumn: pathPrefix.count, length: nextPathComponent.full.count)
194176
)
195177

196178
case .lookupCollision(partialResult: let partialResult, remaining: let remaining, collisions: let collisions):
197179
let nextPathComponent = remaining.first!
198180

199-
var validPrefix = ""
200-
if !partialResult.path.isEmpty {
201-
validPrefix += PathHierarchy.joined(partialResult.path) + "/"
202-
}
203-
validPrefix += nextPathComponent.name
181+
let pathPrefix = partialResult.pathPrefix + nextPathComponent.name
204182

205183
let disambiguations = nextPathComponent.full.dropFirst(nextPathComponent.name.count)
206-
let replacementRange = SourceRange.makeRelativeRange(startColumn: validPrefix.count, length: disambiguations.count)
184+
let replacementRange = SourceRange.makeRelativeRange(startColumn: pathPrefix.count, length: disambiguations.count)
207185

208186
let solutions: [Solution] = collisions.sorted(by: collisionIsBefore).map { (node: PathHierarchy.Node, disambiguation: String) -> Solution in
209-
return Solution(summary: "\(Self.replacementOperationDescription(from: disambiguations.dropFirst(), to: disambiguation)) for\n\(node.fullNameOfValue(context: context).singleQuoted)", replacements: [
187+
return Solution(summary: "\(Self.replacementOperationDescription(from: disambiguations.dropFirst(), to: disambiguation)) for\n\(fullNameOfNode(node).singleQuoted)", replacements: [
210188
Replacement(range: replacementRange, replacement: "-" + disambiguation)
211189
])
212190
}
@@ -215,7 +193,7 @@ extension PathHierarchy.Error {
215193
\(nextPathComponent.full.singleQuoted) is ambiguous at \(partialResult.node.pathWithoutDisambiguation().singleQuoted)
216194
""",
217195
solutions: solutions,
218-
rangeAdjustment: .makeRelativeRange(startColumn: validPrefix.count - nextPathComponent.full.count, length: nextPathComponent.full.count)
196+
rangeAdjustment: .makeRelativeRange(startColumn: pathPrefix.count - nextPathComponent.full.count, length: nextPathComponent.full.count)
219197
)
220198
}
221199
}
@@ -244,26 +222,6 @@ private extension PathHierarchy.Node {
244222
}
245223
return "/" + components.joined(separator: "/")
246224
}
247-
248-
/// Determines the full name of a node's value using information from the documentation context.
249-
///
250-
/// > Note: This value is only intended for error messages and other presentation.
251-
func fullNameOfValue(context: DocumentationContext) -> String {
252-
guard let identifier = identifier else { return name }
253-
if let symbol = symbol {
254-
if let fragments = symbol[mixin: SymbolGraph.Symbol.DeclarationFragments.self]?.declarationFragments {
255-
return fragments.map(\.spelling).joined().split(whereSeparator: { $0.isWhitespace || $0.isNewline }).joined(separator: " ")
256-
}
257-
return context.nodeWithSymbolIdentifier(symbol.identifier.precise)!.name.description
258-
}
259-
// This only gets called for PathHierarchy error messages, so hierarchyBasedLinkResolver is never nil.
260-
let reference = context.hierarchyBasedLinkResolver.resolvedReferenceMap[identifier]!
261-
if reference.fragment != nil {
262-
return context.nodeAnchorSections[reference]!.title
263-
} else {
264-
return context.documentationCache[reference]!.name.description
265-
}
266-
}
267225
}
268226

269227
private extension SourceRange {

0 commit comments

Comments
 (0)