Skip to content

Commit 94d8aca

Browse files
authored
Fix #438 (Structure suggestions for link resolution warnings as fixits) (#455)
* fix #438 (Structure suggestions for link resolution warnings as fixits) * address review feedback - create TopicReferenceResolutionError with minimal public interface - replace TopicReferenceResolutionResult.failure's errorMessage with TopicReferenceResolutionError - add support for non-symbol reference syntax - add Solution for PathHierarchy.Error.nonSymbolMatchForSymbolLink - add meaningful information to Solution.summary - scope Replacements as small as possible (on disambiguation, path segment, or reference delimiters) - add note listing all available candidates even if near-miss suggestions are available - add Solutions if no near-miss is available, but there are only three candidates or less - make offsetWithRange mutating - remove XCTAssertElements test helper * address review feedback - add DescribedError conformance TopicReferenceResolutionError - improve Hashable implementation of TopicReferenceResolutionError by storing properties demanded by DescribedError instead of raw Error - conform Replacement and Solution to Hashable to aid Hashable implementation of TopicReferenceResolutionError - add fixme regarding support for regular markdown link-style references when creating solutions (#470) - add basic semantic information about possible solutions back into PathHierarchyTests - remove debug print in SymbolTests * add assertions for solutions in SymbolTests/testUnresolvedReferenceWarningsInDocComment() + improve improve documentation about indices in source locations --------- Co-authored-by: David Rönnqvist <[email protected]> rdar://103279313
1 parent f74d3ba commit 94d8aca

19 files changed

+904
-154
lines changed

Sources/SwiftDocC/Infrastructure/Diagnostics/Diagnostic.swift

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -87,21 +87,13 @@ public struct Diagnostic: DescribedError {
8787

8888
public extension Diagnostic {
8989

90-
/// Returns a copy of the diagnostic but offset using a certain `SourceRange`.
90+
/// Offsets the diagnostic using a certain SymbolKit `SourceRange`.
91+
///
9192
/// Useful when validating a doc comment that needs to be projected in its containing file "space".
92-
func offsetedWithRange(_ docRange: SymbolGraph.LineList.SourceRange) -> Diagnostic {
93-
guard let diagnosticRange = range else {
94-
// No location information in the source diagnostic, might be removed for safety reasons.
95-
return self
96-
}
97-
98-
let start = SourceLocation(line: diagnosticRange.lowerBound.line + docRange.start.line, column: diagnosticRange.lowerBound.column + docRange.start.character, source: nil)
99-
let end = SourceLocation(line: diagnosticRange.upperBound.line + docRange.start.line, column: diagnosticRange.upperBound.column + docRange.start.character, source: nil)
100-
101-
// Use the updated source range.
102-
var result = self
103-
result.range = start..<end
104-
return result
93+
mutating func offsetWithRange(_ docRange: SymbolGraph.LineList.SourceRange) {
94+
// If there is no location information in the source diagnostic, the diagnostic might be removed for safety reasons.
95+
range?.offsetWithRange(docRange)
96+
10597
}
10698

10799
var localizedDescription: String {

Sources/SwiftDocC/Infrastructure/Diagnostics/Problem.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
99
*/
1010

11+
import SymbolKit
12+
1113
/**
1214
A problem with a document along with possible solutions to the problem.
1315
*/
@@ -45,7 +47,22 @@ extension Problem {
4547
})
4648
})
4749

48-
return description + fixitString
50+
return description + fixitString
51+
}
52+
}
53+
54+
extension Problem {
55+
/// Offsets the problem using a certain SymbolKit `SourceRange`.
56+
///
57+
/// Useful when validating a doc comment that needs to be projected in its containing file "space".
58+
mutating func offsetWithRange(_ docRange: SymbolGraph.LineList.SourceRange) {
59+
diagnostic.offsetWithRange(docRange)
60+
61+
for i in possibleSolutions.indices {
62+
for j in possibleSolutions[i].replacements.indices {
63+
possibleSolutions[i].replacements[j].offsetWithRange(docRange)
64+
}
65+
}
4966
}
5067
}
5168

Sources/SwiftDocC/Infrastructure/Diagnostics/Replacement.swift

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99
*/
1010

1111
import Markdown
12+
import SymbolKit
1213

1314
/**
1415
A textual replacement.
1516
*/
16-
public struct Replacement {
17+
public struct Replacement: Hashable {
1718
/// The range to replace.
1819
public var range: SourceRange
1920

@@ -25,3 +26,22 @@ public struct Replacement {
2526
self.replacement = replacement
2627
}
2728
}
29+
30+
extension Replacement {
31+
/// Offsets the replacement using a certain `SourceRange`.
32+
///
33+
/// Useful when validating a doc comment that needs to be projected in its containing file "space".
34+
///
35+
/// - Warning: Absolute `SourceRange`s index line and column from 1. Thus, at least one
36+
/// of `self` or `range` must be a relative range indexed from 0.
37+
mutating func offsetWithRange(_ range: SourceRange) {
38+
self.range.offsetWithRange(range)
39+
}
40+
41+
/// Offsets the replacement using a certain SymbolKit `SourceRange`.
42+
///
43+
/// Useful when validating a doc comment that needs to be projected in its containing file "space".
44+
mutating func offsetWithRange(_ docRange: SymbolGraph.LineList.SourceRange) {
45+
self.offsetWithRange(SourceRange(from: docRange))
46+
}
47+
}

Sources/SwiftDocC/Infrastructure/Diagnostics/Solution.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
/**
1212
A solution to a `Problem`.
1313
*/
14-
public struct Solution {
14+
public struct Solution: Hashable {
1515
/// A *brief* description of what the solution is.
1616
public var summary: String
1717

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -624,10 +624,12 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
624624
let docs = documentationNode.symbol?.docComment {
625625
// Offset all problem ranges by the start location of the
626626
// source comment in the context of the complete file.
627-
problems = problems.compactMap { problem -> Problem? in
628-
guard let docRange = docs.lines.first?.range else { return nil }
629-
return Problem(diagnostic: problem.diagnostic.offsetedWithRange(docRange),
630-
possibleSolutions: problem.possibleSolutions)
627+
if let docRange = docs.lines.first?.range {
628+
for i in problems.indices {
629+
problems[i].offsetWithRange(docRange)
630+
}
631+
} else {
632+
problems.removeAll()
631633
}
632634
}
633635

Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ public class OutOfProcessReferenceResolver: ExternalReferenceResolver, FallbackR
123123
do {
124124
guard let unresolvedTopicURL = unresolvedReference.topicURL.components.url else {
125125
// Return the unresolved reference if the underlying URL is not valid
126-
return .failure(unresolvedReference, errorMessage: "URL \(unresolvedReference.topicURL.absoluteString.singleQuoted) is not valid.")
126+
return .failure(unresolvedReference, TopicReferenceResolutionError("URL \(unresolvedReference.topicURL.absoluteString.singleQuoted) is not valid."))
127127
}
128128
let metadata = try resolveInformationForTopicURL(unresolvedTopicURL)
129129
// Don't do anything with this URL. The external URL will be resolved during conversion to render nodes
@@ -136,7 +136,7 @@ public class OutOfProcessReferenceResolver: ExternalReferenceResolver, FallbackR
136136
)
137137
)
138138
} catch let error {
139-
return .failure(unresolvedReference, errorMessage: error.localizedDescription)
139+
return .failure(unresolvedReference, TopicReferenceResolutionError(error))
140140
}
141141
}
142142
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ final class DocumentationCacheBasedLinkResolver {
129129
// Ensure we are resolving either relative links or "doc:" scheme links
130130
guard unresolvedReference.topicURL.url.scheme == nil || ResolvedTopicReference.urlHasResolvedTopicScheme(unresolvedReference.topicURL.url) else {
131131
// Not resolvable in the topic graph
132-
return .failure(unresolvedReference, errorMessage: "Reference URL \(unresolvedReference.description.singleQuoted) doesn't have \"doc:\" scheme.")
132+
return .failure(unresolvedReference, TopicReferenceResolutionError("Reference URL \(unresolvedReference.description.singleQuoted) doesn't have \"doc:\" scheme."))
133133
}
134134

135135
// Fall back on the parent's bundle identifier for relative paths
@@ -267,7 +267,7 @@ final class DocumentationCacheBasedLinkResolver {
267267
// Return the successful or failed externally resolved reference.
268268
return resolvedExternalReference
269269
} else if !context.registeredBundles.contains(where: { $0.identifier == bundleID }) {
270-
return .failure(unresolvedReference, errorMessage: "No external resolver registered for \(bundleID.singleQuoted).")
270+
return .failure(unresolvedReference, TopicReferenceResolutionError("No external resolver registered for \(bundleID.singleQuoted)."))
271271
}
272272
}
273273

@@ -295,7 +295,7 @@ final class DocumentationCacheBasedLinkResolver {
295295
// Give up: there is no local or external document for this reference.
296296

297297
// External references which failed to resolve will already have returned a more specific error message.
298-
return .failure(unresolvedReference, errorMessage: "No local documentation matches this reference.")
298+
return .failure(unresolvedReference, TopicReferenceResolutionError("No local documentation matches this reference."))
299299
}
300300

301301

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

Lines changed: 98 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import Foundation
1212
import SymbolKit
13+
import Markdown
1314

1415
/// An opaque identifier that uniquely identifies a resolved entry in the path hierarchy,
1516
///
@@ -321,7 +322,9 @@ struct PathHierarchy {
321322
}
322323
} catch DisambiguationTree.Error.lookupCollision(let collisions) {
323324
func wrappedCollisionError() -> Error {
324-
Error.lookupCollision(partialResult: node, collisions: collisions)
325+
Error.lookupCollision(partialResult: node,
326+
remainingSubpath: remaining.map(\.full).joined(separator: "/"),
327+
collisions: collisions)
325328
}
326329

327330
// See if the collision can be resolved by looking ahead on level deeper.
@@ -365,6 +368,7 @@ struct PathHierarchy {
365368
// Couldn't resolve the collision by look ahead.
366369
throw Error.lookupCollision(
367370
partialResult: node,
371+
remainingSubpath: remaining.map(\.full).joined(separator: "/"),
368372
collisions: collisions.map { ($0.node, $0.disambiguation) }
369373
)
370374
}
@@ -814,8 +818,9 @@ extension PathHierarchy {
814818
///
815819
/// Includes information about:
816820
/// - The partial result for as much of the path that could be found unambiguously.
821+
/// - The remaining portion of the path.
817822
/// - A list of possible matches paired with the disambiguation suffixes needed to distinguish them.
818-
case lookupCollision(partialResult: Node, collisions: [(node: Node, disambiguation: String)])
823+
case lookupCollision(partialResult: Node, remainingSubpath: String, collisions: [(node: Node, disambiguation: String)])
819824
}
820825
}
821826

@@ -825,30 +830,101 @@ private func availableChildNameIsBefore(_ lhs: String, _ rhs: String) -> Bool {
825830
}
826831

827832
extension PathHierarchy.Error {
828-
/// Formats the error into an error message suitable for presentation
829-
func errorMessage(context: DocumentationContext) -> String {
833+
/// Generate a ``TopicReferenceResolutionError`` from this error using the given `context` and `originalReference`.
834+
///
835+
/// The resulting ``TopicReferenceResolutionError`` is human-readable and provides helpful solutions.
836+
///
837+
/// - Parameters:
838+
/// - context: The ``DocumentationContext`` the `originalReference` was resolved in.
839+
/// - originalReference: The raw input string that represents the body of the reference that failed to resolve. This string is
840+
/// used to calculate the proper replacment-ranges for fixits.
841+
///
842+
/// - Note: `Replacement`s produced by this function use `SourceLocation`s relative to the `originalReference`, i.e. the beginning
843+
/// of the _body_ of the original reference.
844+
func asTopicReferenceResolutionError(context: DocumentationContext, originalReference: String) -> TopicReferenceResolutionError {
830845
switch self {
831-
case .partialResult(let partialResult, let remaining, let available):
832-
let nearMisses = NearMiss.bestMatches(for: available, against: remaining)
833-
let suggestion: String
834-
switch nearMisses.count {
835-
case 0:
836-
suggestion = "No similar pages. Available children: \(available.joined(separator: ", "))."
837-
case 1:
838-
suggestion = "Did you mean: \(nearMisses[0])?"
839-
default:
840-
suggestion = "Did you mean one of: \(nearMisses.joined(separator: ", "))?"
846+
case .notFound(availableChildren: let availableChildren):
847+
return TopicReferenceResolutionError("No local documentation matches this reference.", note: availabilityNote(category: "top-level elements", candidates: availableChildren))
848+
case .unfindableMatch:
849+
return TopicReferenceResolutionError("No local documentation matches this reference.")
850+
case .nonSymbolMatchForSymbolLink:
851+
return TopicReferenceResolutionError("Symbol links can only resolve symbols.", solutions: [
852+
Solution(summary: "Use a '<doc:>' style reference.", replacements: [
853+
// the SourceRange points to the opening double-backtick
854+
Replacement(range: SourceLocation(line: 0, column: -2, source: nil)..<SourceLocation(line: 0, column: 0, source: nil), replacement: "<doc:"),
855+
// the SourceRange points to the closing double-backtick
856+
Replacement(range: SourceLocation(line: 0, column: originalReference.count, source: nil)..<SourceLocation(line: 0, column: originalReference.count+2, source: nil), replacement: ">"),
857+
])
858+
])
859+
case .partialResult(partialResult: let partialResult, remainingSubpath: let remainingSubpath, availableChildren: let availableChildren):
860+
let nearMisses = NearMiss.bestMatches(for: availableChildren, against: remainingSubpath)
861+
862+
let validPrefix = originalReference.dropLast(remainingSubpath.count)
863+
864+
let unprocessedSuffix = remainingSubpath.suffix(from: remainingSubpath.firstIndex(of: "/") ?? remainingSubpath.endIndex)
865+
866+
let replacementRange = SourceLocation(line: 0, column: validPrefix.count, source: nil)..<SourceLocation(line: 0, column: originalReference.count-unprocessedSuffix.count, source: nil)
867+
868+
// we don't want to provide an endless amount of unrelated fixits, but if there are only three
869+
// or less valid options, we still suggest them as fixits
870+
let fixitCandidates = nearMisses.isEmpty && availableChildren.count <= 3 ? availableChildren : nearMisses
871+
872+
let solutions = fixitCandidates.map { candidate in
873+
Solution(summary: "Correct reference to \(validPrefix + candidate + unprocessedSuffix).", replacements: [
874+
Replacement(range: replacementRange, replacement: candidate)
875+
])
841876
}
842-
return "Reference at \(partialResult.pathWithoutDisambiguation().singleQuoted) can't resolve \(remaining.singleQuoted). \(suggestion)"
843-
case .notFound, .unfindableMatch:
844-
return "No local documentation matches this reference."
845877

846-
case .nonSymbolMatchForSymbolLink:
847-
return "Symbol links can only resolve symbols."
848878

849-
case .lookupCollision(let partialResult, let collisions):
850-
let collisionDescription = collisions.map { "Append '-\($0.disambiguation)' to refer to \($0.node.fullNameOfValue(context: context).singleQuoted)" }.sorted()
851-
return "Reference is ambiguous after \(partialResult.pathWithoutDisambiguation().singleQuoted): \(collisionDescription.joined(separator: ". "))."
879+
var message: String {
880+
var suggestion: String {
881+
switch nearMisses.count {
882+
case 0:
883+
return "No similar pages."
884+
case 1:
885+
return "Did you mean \(nearMisses[0])?"
886+
default:
887+
return "Did you mean one of: \(nearMisses.joined(separator: ", "))?"
888+
}
889+
}
890+
891+
return "Reference at \(partialResult.pathWithoutDisambiguation().singleQuoted) can't resolve \(remainingSubpath.singleQuoted). \(suggestion)"
892+
}
893+
894+
return TopicReferenceResolutionError(message, note: availabilityNote(category: "children", candidates: availableChildren), solutions: solutions)
895+
896+
case .lookupCollision(partialResult: let partialResult, remainingSubpath: let remainingSubpath, collisions: let collisions):
897+
let unprocessedSuffix = remainingSubpath.suffix(from: remainingSubpath.firstIndex(of: "/") ?? remainingSubpath.endIndex)
898+
899+
let prefixPotentiallyIncludingInvalidDisambiguation = originalReference.dropLast(unprocessedSuffix.count)
900+
let lowerBoundOfLastSegment = prefixPotentiallyIncludingInvalidDisambiguation.lastIndex(of: "/")
901+
?? prefixPotentiallyIncludingInvalidDisambiguation.startIndex
902+
let lowerBoundOfLastDisambiguation = prefixPotentiallyIncludingInvalidDisambiguation.lastIndex(of: "-")
903+
?? prefixPotentiallyIncludingInvalidDisambiguation.startIndex
904+
let lowerBoundOfInvalidDisambiguation = lowerBoundOfLastDisambiguation > lowerBoundOfLastSegment ? lowerBoundOfLastDisambiguation : nil
905+
let validPrefix = prefixPotentiallyIncludingInvalidDisambiguation[..<(lowerBoundOfInvalidDisambiguation ?? prefixPotentiallyIncludingInvalidDisambiguation.endIndex)]
906+
907+
let replacementRange = SourceLocation(line: 0, column: validPrefix.count, source: nil)..<SourceLocation(line: 0, column: originalReference.count-unprocessedSuffix.count, source: nil)
908+
909+
let solutions = collisions.sorted(by: {
910+
$0.node.fullNameOfValue(context: context) + $0.disambiguation
911+
< $1.node.fullNameOfValue(context: context) + $1.disambiguation
912+
}).map { collision in
913+
Solution(summary: "Insert disambiguation suffix for \(collision.node.fullNameOfValue(context: context).singleQuoted)", replacements: [
914+
Replacement(range: replacementRange, replacement: "-" + collision.disambiguation)
915+
])
916+
}
917+
918+
return TopicReferenceResolutionError("Reference is ambiguous after \(partialResult.pathWithoutDisambiguation().singleQuoted).", solutions: solutions)
919+
}
920+
}
921+
922+
private func availabilityNote(category: String, candidates: [String]) -> String {
923+
switch candidates.count {
924+
case 0:
925+
return "No \(category) available."
926+
default:
927+
return "Available \(category): \(candidates.joined(separator: ", "))."
852928
}
853929
}
854930
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ final class PathHierarchyBasedLinkResolver {
207207
// Return the successful or failed externally resolved reference.
208208
return resolvedExternalReference
209209
} else if !context.registeredBundles.contains(where: { $0.identifier == bundleID }) {
210-
return .failure(unresolvedReference, errorMessage: "No external resolver registered for \(bundleID.singleQuoted).")
210+
return .failure(unresolvedReference, TopicReferenceResolutionError("No external resolver registered for \(bundleID.singleQuoted)."))
211211
}
212212
}
213213

@@ -222,7 +222,12 @@ final class PathHierarchyBasedLinkResolver {
222222
if let resolvedFallbackReference = fallbackResolver.resolve(unresolvedReference, in: parent, fromSymbolLink: isCurrentlyResolvingSymbolLink, context: context) {
223223
return .success(resolvedFallbackReference)
224224
} else {
225-
return .failure(unresolvedReference, errorMessage: error.errorMessage(context: context))
225+
var originalReferenceString = unresolvedReference.path
226+
if let fragment = unresolvedReference.topicURL.components.fragment {
227+
originalReferenceString += "#" + fragment
228+
}
229+
230+
return .failure(unresolvedReference, error.asTopicReferenceResolutionError(context: context, originalReference: originalReferenceString))
226231
}
227232
} catch {
228233
fatalError("Only SymbolPathTree.Error errors are raised from the symbol link resolution code above.")

0 commit comments

Comments
 (0)