diff --git a/Sources/SwiftDocC/Infrastructure/Diagnostics/Diagnostic.swift b/Sources/SwiftDocC/Infrastructure/Diagnostics/Diagnostic.swift index cf05619d91..2e6934b770 100644 --- a/Sources/SwiftDocC/Infrastructure/Diagnostics/Diagnostic.swift +++ b/Sources/SwiftDocC/Infrastructure/Diagnostics/Diagnostic.swift @@ -87,21 +87,13 @@ public struct Diagnostic: DescribedError { public extension Diagnostic { - /// Returns a copy of the diagnostic but offset using a certain `SourceRange`. + /// Offsets the diagnostic using a certain SymbolKit `SourceRange`. + /// /// Useful when validating a doc comment that needs to be projected in its containing file "space". - func offsetedWithRange(_ docRange: SymbolGraph.LineList.SourceRange) -> Diagnostic { - guard let diagnosticRange = range else { - // No location information in the source diagnostic, might be removed for safety reasons. - return self - } - - let start = SourceLocation(line: diagnosticRange.lowerBound.line + docRange.start.line, column: diagnosticRange.lowerBound.column + docRange.start.character, source: nil) - let end = SourceLocation(line: diagnosticRange.upperBound.line + docRange.start.line, column: diagnosticRange.upperBound.column + docRange.start.character, source: nil) - - // Use the updated source range. - var result = self - result.range = start.. Problem? in - guard let docRange = docs.lines.first?.range else { return nil } - return Problem(diagnostic: problem.diagnostic.offsetedWithRange(docRange), - possibleSolutions: problem.possibleSolutions) + if let docRange = docs.lines.first?.range { + for i in problems.indices { + problems[i].offsetWithRange(docRange) + } + } else { + problems.removeAll() } } diff --git a/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver.swift b/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver.swift index 83c60644d5..d71741f795 100644 --- a/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver.swift @@ -123,7 +123,7 @@ public class OutOfProcessReferenceResolver: ExternalReferenceResolver, FallbackR do { guard let unresolvedTopicURL = unresolvedReference.topicURL.components.url else { // Return the unresolved reference if the underlying URL is not valid - return .failure(unresolvedReference, errorMessage: "URL \(unresolvedReference.topicURL.absoluteString.singleQuoted) is not valid.") + return .failure(unresolvedReference, TopicReferenceResolutionError("URL \(unresolvedReference.topicURL.absoluteString.singleQuoted) is not valid.")) } let metadata = try resolveInformationForTopicURL(unresolvedTopicURL) // 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 ) ) } catch let error { - return .failure(unresolvedReference, errorMessage: error.localizedDescription) + return .failure(unresolvedReference, TopicReferenceResolutionError(error)) } } } diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/DocumentationCacheBasedLinkResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/DocumentationCacheBasedLinkResolver.swift index 652c06429b..559bfe09e4 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/DocumentationCacheBasedLinkResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/DocumentationCacheBasedLinkResolver.swift @@ -129,7 +129,7 @@ final class DocumentationCacheBasedLinkResolver { // Ensure we are resolving either relative links or "doc:" scheme links guard unresolvedReference.topicURL.url.scheme == nil || ResolvedTopicReference.urlHasResolvedTopicScheme(unresolvedReference.topicURL.url) else { // Not resolvable in the topic graph - return .failure(unresolvedReference, errorMessage: "Reference URL \(unresolvedReference.description.singleQuoted) doesn't have \"doc:\" scheme.") + return .failure(unresolvedReference, TopicReferenceResolutionError("Reference URL \(unresolvedReference.description.singleQuoted) doesn't have \"doc:\" scheme.")) } // Fall back on the parent's bundle identifier for relative paths @@ -267,7 +267,7 @@ final class DocumentationCacheBasedLinkResolver { // Return the successful or failed externally resolved reference. return resolvedExternalReference } else if !context.registeredBundles.contains(where: { $0.identifier == bundleID }) { - return .failure(unresolvedReference, errorMessage: "No external resolver registered for \(bundleID.singleQuoted).") + return .failure(unresolvedReference, TopicReferenceResolutionError("No external resolver registered for \(bundleID.singleQuoted).")) } } @@ -295,7 +295,7 @@ final class DocumentationCacheBasedLinkResolver { // Give up: there is no local or external document for this reference. // External references which failed to resolve will already have returned a more specific error message. - return .failure(unresolvedReference, errorMessage: "No local documentation matches this reference.") + return .failure(unresolvedReference, TopicReferenceResolutionError("No local documentation matches this reference.")) } diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift index 75f2f55beb..7abbb94d99 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift @@ -10,6 +10,7 @@ import Foundation import SymbolKit +import Markdown /// An opaque identifier that uniquely identifies a resolved entry in the path hierarchy, /// @@ -321,7 +322,9 @@ struct PathHierarchy { } } catch DisambiguationTree.Error.lookupCollision(let collisions) { func wrappedCollisionError() -> Error { - Error.lookupCollision(partialResult: node, collisions: collisions) + Error.lookupCollision(partialResult: node, + remainingSubpath: remaining.map(\.full).joined(separator: "/"), + collisions: collisions) } // See if the collision can be resolved by looking ahead on level deeper. @@ -365,6 +368,7 @@ struct PathHierarchy { // Couldn't resolve the collision by look ahead. throw Error.lookupCollision( partialResult: node, + remainingSubpath: remaining.map(\.full).joined(separator: "/"), collisions: collisions.map { ($0.node, $0.disambiguation) } ) } @@ -814,8 +818,9 @@ extension PathHierarchy { /// /// Includes information about: /// - The partial result for as much of the path that could be found unambiguously. + /// - The remaining portion of the path. /// - A list of possible matches paired with the disambiguation suffixes needed to distinguish them. - case lookupCollision(partialResult: Node, collisions: [(node: Node, disambiguation: String)]) + case lookupCollision(partialResult: Node, remainingSubpath: String, collisions: [(node: Node, disambiguation: String)]) } } @@ -825,30 +830,101 @@ private func availableChildNameIsBefore(_ lhs: String, _ rhs: String) -> Bool { } extension PathHierarchy.Error { - /// Formats the error into an error message suitable for presentation - func errorMessage(context: DocumentationContext) -> String { + /// Generate a ``TopicReferenceResolutionError`` from this error using the given `context` and `originalReference`. + /// + /// The resulting ``TopicReferenceResolutionError`` is human-readable and provides helpful solutions. + /// + /// - Parameters: + /// - context: The ``DocumentationContext`` the `originalReference` was resolved in. + /// - originalReference: The raw input string that represents the body of the reference that failed to resolve. This string is + /// used to calculate the proper replacment-ranges for fixits. + /// + /// - Note: `Replacement`s produced by this function use `SourceLocation`s relative to the `originalReference`, i.e. the beginning + /// of the _body_ of the original reference. + func asTopicReferenceResolutionError(context: DocumentationContext, originalReference: String) -> TopicReferenceResolutionError { switch self { - case .partialResult(let partialResult, let remaining, let available): - let nearMisses = NearMiss.bestMatches(for: available, against: remaining) - let suggestion: String - switch nearMisses.count { - case 0: - suggestion = "No similar pages. Available children: \(available.joined(separator: ", "))." - case 1: - suggestion = "Did you mean: \(nearMisses[0])?" - default: - suggestion = "Did you mean one of: \(nearMisses.joined(separator: ", "))?" + case .notFound(availableChildren: let availableChildren): + return TopicReferenceResolutionError("No local documentation matches this reference.", note: availabilityNote(category: "top-level elements", candidates: availableChildren)) + case .unfindableMatch: + return TopicReferenceResolutionError("No local documentation matches this reference.") + case .nonSymbolMatchForSymbolLink: + return TopicReferenceResolutionError("Symbol links can only resolve symbols.", solutions: [ + Solution(summary: "Use a '' style reference.", replacements: [ + // the SourceRange points to the opening double-backtick + Replacement(range: SourceLocation(line: 0, column: -2, source: nil).."), + ]) + ]) + case .partialResult(partialResult: let partialResult, remainingSubpath: let remainingSubpath, availableChildren: let availableChildren): + let nearMisses = NearMiss.bestMatches(for: availableChildren, against: remainingSubpath) + + let validPrefix = originalReference.dropLast(remainingSubpath.count) + + let unprocessedSuffix = remainingSubpath.suffix(from: remainingSubpath.firstIndex(of: "/") ?? remainingSubpath.endIndex) + + let replacementRange = SourceLocation(line: 0, column: validPrefix.count, source: nil).. lowerBoundOfLastSegment ? lowerBoundOfLastDisambiguation : nil + let validPrefix = prefixPotentiallyIncludingInvalidDisambiguation[..<(lowerBoundOfInvalidDisambiguation ?? prefixPotentiallyIncludingInvalidDisambiguation.endIndex)] + + let replacementRange = SourceLocation(line: 0, column: validPrefix.count, source: nil).. String { + switch candidates.count { + case 0: + return "No \(category) available." + default: + return "Available \(category): \(candidates.joined(separator: ", "))." } } } diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchyBasedLinkResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchyBasedLinkResolver.swift index 85d3f7d048..488518e0aa 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchyBasedLinkResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchyBasedLinkResolver.swift @@ -207,7 +207,7 @@ final class PathHierarchyBasedLinkResolver { // Return the successful or failed externally resolved reference. return resolvedExternalReference } else if !context.registeredBundles.contains(where: { $0.identifier == bundleID }) { - return .failure(unresolvedReference, errorMessage: "No external resolver registered for \(bundleID.singleQuoted).") + return .failure(unresolvedReference, TopicReferenceResolutionError("No external resolver registered for \(bundleID.singleQuoted).")) } } @@ -222,7 +222,12 @@ final class PathHierarchyBasedLinkResolver { if let resolvedFallbackReference = fallbackResolver.resolve(unresolvedReference, in: parent, fromSymbolLink: isCurrentlyResolvingSymbolLink, context: context) { return .success(resolvedFallbackReference) } else { - return .failure(unresolvedReference, errorMessage: error.errorMessage(context: context)) + var originalReferenceString = unresolvedReference.path + if let fragment = unresolvedReference.topicURL.components.fragment { + originalReferenceString += "#" + fragment + } + + return .failure(unresolvedReference, error.asTopicReferenceResolutionError(context: context, originalReference: originalReferenceString)) } } catch { fatalError("Only SymbolPathTree.Error errors are raised from the symbol link resolution code above.") diff --git a/Sources/SwiftDocC/Model/DocumentationNode.swift b/Sources/SwiftDocC/Model/DocumentationNode.swift index 29d51a7ff1..48e648ab11 100644 --- a/Sources/SwiftDocC/Model/DocumentationNode.swift +++ b/Sources/SwiftDocC/Model/DocumentationNode.swift @@ -423,7 +423,7 @@ public struct DocumentationNode { continue } - var diagnostic = Diagnostic( + let diagnostic = Diagnostic( source: location, severity: .warning, range: range, @@ -432,11 +432,13 @@ public struct DocumentationNode { explanation: "Found \(comment.name.singleQuoted) in \(symbol.absolutePath.singleQuoted)" ) + var problem = Problem(diagnostic: diagnostic, possibleSolutions: []) + if let offset = docComment.lines.first?.range { - diagnostic = diagnostic.offsetedWithRange(offset) + problem.offsetWithRange(offset) } - engine.emit(Problem(diagnostic: diagnostic, possibleSolutions: [])) + engine.emit(problem) } } diff --git a/Sources/SwiftDocC/Model/Identifier.swift b/Sources/SwiftDocC/Model/Identifier.swift index 3180e15aba..c6798dc7a4 100644 --- a/Sources/SwiftDocC/Model/Identifier.swift +++ b/Sources/SwiftDocC/Model/Identifier.swift @@ -10,6 +10,7 @@ import Foundation import SymbolKit +import Markdown /// A resolved or unresolved reference to a piece of documentation. /// @@ -54,7 +55,7 @@ public enum TopicReferenceResolutionResult: Hashable, CustomStringConvertible { /// A topic reference that has successfully been resolved to known documentation. case success(ResolvedTopicReference) /// A topic reference that has failed to resolve to known documentation and an error message with information about why the reference failed to resolve. - case failure(UnresolvedTopicReference, errorMessage: String) + case failure(UnresolvedTopicReference, TopicReferenceResolutionError) public var description: String { switch self { @@ -66,6 +67,60 @@ public enum TopicReferenceResolutionResult: Hashable, CustomStringConvertible { } } +/// The error causing the failure in the resolution of a ``TopicReference``. +public struct TopicReferenceResolutionError: DescribedError, Hashable { + public let errorDescription: String + public let recoverySuggestion: String? + public let failureReason: String? + public let helpAnchor: String? + private let solutions: [Solution] + + init(_ message: String, note: String? = nil, solutions: [Solution] = []) { + self.errorDescription = message + self.recoverySuggestion = note + self.failureReason = nil + self.helpAnchor = nil + self.solutions = solutions + } +} + +extension TopicReferenceResolutionError { + init(_ error: Error, solutions: [Solution] = []) { + if let describedError = error as? DescribedError { + self.errorDescription = describedError.errorDescription + self.recoverySuggestion = describedError.recoverySuggestion + self.failureReason = describedError.failureReason + self.helpAnchor = describedError.helpAnchor + } else { + self.errorDescription = error.localizedDescription + self.recoverySuggestion = nil + self.failureReason = nil + self.helpAnchor = nil + } + self.solutions = solutions + } +} + +extension TopicReferenceResolutionError { + /// Extracts any `Solution`s from this error, if available. + /// + /// The error can provide `Solution`s if appropriate. Since the absolute location of + /// the faulty reference is not known at the error's origin, the `Replacement`s + /// will use `SourceLocation`s relative to the reference text. Provide range of the + /// reference **body** to obtain correctly placed `Replacement`s. + func solutions(referenceSourceRange: SourceRange) -> [Solution] { + var solutions = self.solutions + + for i in solutions.indices { + for j in solutions[i].replacements.indices { + solutions[i].replacements[j].offsetWithRange(referenceSourceRange) + } + } + + return solutions + } +} + /// A reference to a piece of documentation which has been verified to exist. /// /// A `ResolvedTopicReference` refers to some piece of documentation, such as an article or symbol. diff --git a/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift b/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift index 7d31fb4fed..45dc334f35 100644 --- a/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift +++ b/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift @@ -63,16 +63,15 @@ struct MarkupReferenceResolver: MarkupRewriter { } return resolved - case .failure(let unresolved, let errorMessage): + case .failure(let unresolved, let error): if let callback = problemForUnresolvedReference, - let problem = callback(unresolved, source, range, fromSymbolLink, errorMessage) { + let problem = callback(unresolved, source, range, fromSymbolLink, error.localizedDescription) { problems.append(problem) return nil } - // FIXME: Structure the `PathHierarchyBasedLinkResolver` near-miss suggestions as fixits. https://github.com/apple/swift-docc/issues/438 (rdar://103279313) let uncuratedArticleMatch = context.uncuratedArticles[bundle.articlesDocumentationRootReference.appendingPathOfReference(unresolved)]?.source - problems.append(unresolvedReferenceProblem(reference: reference, source: source, range: range, severity: severity, uncuratedArticleMatch: uncuratedArticleMatch, underlyingErrorMessage: errorMessage)) + problems.append(unresolvedReferenceProblem(reference: reference, source: source, range: range, severity: severity, uncuratedArticleMatch: uncuratedArticleMatch, underlyingError: error, fromSymbolLink: fromSymbolLink)) return nil } } diff --git a/Sources/SwiftDocC/Semantics/ReferenceResolver.swift b/Sources/SwiftDocC/Semantics/ReferenceResolver.swift index 54c472e51d..8eead29118 100644 --- a/Sources/SwiftDocC/Semantics/ReferenceResolver.swift +++ b/Sources/SwiftDocC/Semantics/ReferenceResolver.swift @@ -11,13 +11,34 @@ import Foundation import Markdown -func unresolvedReferenceProblem(reference: TopicReference, source: URL?, range: SourceRange?, severity: DiagnosticSeverity, uncuratedArticleMatch: URL?, underlyingErrorMessage: String) -> Problem { - let notes = uncuratedArticleMatch.map { +func unresolvedReferenceProblem(reference: TopicReference, source: URL?, range: SourceRange?, severity: DiagnosticSeverity, uncuratedArticleMatch: URL?, underlyingError: TopicReferenceResolutionError, fromSymbolLink: Bool) -> Problem { + var notes = uncuratedArticleMatch.map { [DiagnosticNote(source: $0, range: SourceLocation(line: 1, column: 1, source: nil).. + : SourceLocation(line: range.lowerBound.line, column: range.lowerBound.column+5, source: range.lowerBound.source).. Problem? in // Verify we have all the information about the location of the source comment // and the symbol that the comment is inherited from. - if let parent = parent, let range = range, - let symbol = try? context.entity(with: parent).symbol, - let docLines = symbol.docComment, - let docStartLine = docLines.lines.first?.range?.start.line, - let docStartColumn = docLines.lines.first?.range?.start.character { - + if let parent = parent, let range = range { switch context.resolve(.unresolved(unresolved), in: parent, fromSymbolLink: fromSymbolLink) { case .success(let resolved): - - // Make the range for the suggested replacement. - let start = SourceLocation(line: docStartLine + range.lowerBound.line, column: docStartColumn + range.lowerBound.column, source: range.lowerBound.source) - let end = SourceLocation(line: docStartLine + range.upperBound.line, column: docStartColumn + range.upperBound.column, source: range.upperBound.source) - let replacementRange = SourceRange(uncheckedBounds: (lower: start, upper: end)) - // Return a warning with a suggested change that replaces the relative link with an absolute one. return Problem(diagnostic: Diagnostic(source: source, severity: .warning, range: range, @@ -209,7 +218,7 @@ struct ReferenceResolver: SemanticVisitor { summary: "This documentation block is inherited by other symbols where \(unresolved.topicURL.absoluteString.singleQuoted) fails to resolve."), possibleSolutions: [ Solution(summary: "Use an absolute link path.", replacements: [ - Replacement(range: replacementRange, replacement: "") + Replacement(range: range, replacement: "") ]) ]) default: break diff --git a/Sources/SwiftDocC/Utility/MarkupExtensions/SourceRangeExtensions.swift b/Sources/SwiftDocC/Utility/MarkupExtensions/SourceRangeExtensions.swift new file mode 100644 index 0000000000..4e14164612 --- /dev/null +++ b/Sources/SwiftDocC/Utility/MarkupExtensions/SourceRangeExtensions.swift @@ -0,0 +1,41 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2023 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Markdown +import SymbolKit + +extension SourceRange { + /// Offsets the `SourceRange` using a SymbolKit `SourceRange`. + mutating func offsetWithRange(_ range: SymbolGraph.LineList.SourceRange) { + self.offsetWithRange(SourceRange(from: range)) + } + + /// Initialize a `SourceRange` from a SymbolKit `SourceRange`. + init(from symbolGrapSourceRange: SymbolGraph.LineList.SourceRange) { + self = SourceLocation(line: symbolGrapSourceRange.start.line, + column: symbolGrapSourceRange.start.character, + source: nil).. Int { 0 } @@ -314,24 +318,26 @@ class PathHierarchyTests: XCTestCase { (symbolID: "s:14MixedFramework29CollisionsWithEscapedKeywordsC4inityyF", disambiguation: "method"), (symbolID: "s:14MixedFramework29CollisionsWithEscapedKeywordsC4inityyFZ", disambiguation: "type.method"), ]) - try assertPathRaisesErrorMessage("/MixedFramework/CollisionsWithEscapedKeywords/init()", in: tree, context: context, expectedErrorMessage: """ - Reference is ambiguous after '/MixedFramework/CollisionsWithEscapedKeywords': \ - Append '-init' to refer to 'init()'. \ - Append '-method' to refer to 'func `init`()'. \ - Append '-type.method' to refer to 'static func `init`()'. - """) + try assertPathRaisesErrorMessage("/MixedFramework/CollisionsWithEscapedKeywords/init()", in: tree, context: context, expectedErrorMessage: "Reference is ambiguous after '/MixedFramework/CollisionsWithEscapedKeywords'.") { error in + XCTAssertEqual(error.solutions, [ + .init(summary: "Insert disambiguation suffix for 'func `init`()'", replacements: ["-method"]), + .init(summary: "Insert disambiguation suffix for 'init()'", replacements: ["-init"]), + .init(summary: "Insert disambiguation suffix for 'static func `init`()'", replacements: ["-type.method"]), + ]) + } try assertPathCollision("/MixedFramework/CollisionsWithEscapedKeywords/subscript()", in: tree, collisions: [ (symbolID: "s:14MixedFramework29CollisionsWithEscapedKeywordsC9subscriptyyF", disambiguation: "method"), (symbolID: "s:14MixedFramework29CollisionsWithEscapedKeywordsCSiycip", disambiguation: "subscript"), (symbolID: "s:14MixedFramework29CollisionsWithEscapedKeywordsC9subscriptyyFZ", disambiguation: "type.method"), ]) - try assertPathRaisesErrorMessage("/MixedFramework/CollisionsWithEscapedKeywords/subscript()", in: tree, context: context, expectedErrorMessage: """ - Reference is ambiguous after '/MixedFramework/CollisionsWithEscapedKeywords': \ - Append '-method' to refer to 'func `subscript`()'. \ - Append '-subscript' to refer to 'subscript() -> Int { get }'. \ - Append '-type.method' to refer to 'static func `subscript`()'. - """) + try assertPathRaisesErrorMessage("/MixedFramework/CollisionsWithEscapedKeywords/subscript()", in: tree, context: context, expectedErrorMessage: "Reference is ambiguous after '/MixedFramework/CollisionsWithEscapedKeywords'.") { error in + XCTAssertEqual(error.solutions, [ + .init(summary: "Insert disambiguation suffix for 'func `subscript`()'", replacements: ["-method"]), + .init(summary: "Insert disambiguation suffix for 'static func `subscript`()'", replacements: ["-type.method"]), + .init(summary: "Insert disambiguation suffix for 'subscript() -> Int { get }'", replacements: ["-subscript"]), + ]) + } // public enum CollisionsWithDifferentFunctionArguments { // public func something(argument: Int) -> Int { 0 } @@ -341,11 +347,15 @@ class PathHierarchyTests: XCTestCase { (symbolID: "s:14MixedFramework40CollisionsWithDifferentFunctionArgumentsO9something8argumentS2i_tF", disambiguation: "1cyvp"), (symbolID: "s:14MixedFramework40CollisionsWithDifferentFunctionArgumentsO9something8argumentSiSS_tF", disambiguation: "2vke2"), ]) - try assertPathRaisesErrorMessage("/MixedFramework/CollisionsWithDifferentFunctionArguments/something(argument:)", in: tree, context: context, expectedErrorMessage: """ - Reference is ambiguous after '/MixedFramework/CollisionsWithDifferentFunctionArguments': \ - Append '-1cyvp' to refer to 'func something(argument: Int) -> Int'. \ - Append '-2vke2' to refer to 'func something(argument: String) -> Int'. - """) + try assertPathRaisesErrorMessage("/MixedFramework/CollisionsWithDifferentFunctionArguments/something(argument:)", + in: tree, + context: context, + expectedErrorMessage: "Reference is ambiguous after '/MixedFramework/CollisionsWithDifferentFunctionArguments'.") { error in + XCTAssertEqual(error.solutions, [ + .init(summary: "Insert disambiguation suffix for 'func something(argument: Int) -> Int'", replacements: ["-1cyvp"]), + .init(summary: "Insert disambiguation suffix for 'func something(argument: String) -> Int'", replacements: ["-2vke2"]), + ]) + } // public enum CollisionsWithDifferentSubscriptArguments { // public subscript(something: Int) -> Int { 0 } @@ -355,11 +365,12 @@ class PathHierarchyTests: XCTestCase { (symbolID: "s:14MixedFramework41CollisionsWithDifferentSubscriptArgumentsOyS2icip", disambiguation: "4fd0l"), (symbolID: "s:14MixedFramework41CollisionsWithDifferentSubscriptArgumentsOySiSScip", disambiguation: "757cj"), ]) - try assertPathRaisesErrorMessage("/MixedFramework/CollisionsWithDifferentSubscriptArguments/subscript(_:)", in: tree, context: context, expectedErrorMessage: """ - Reference is ambiguous after '/MixedFramework/CollisionsWithDifferentSubscriptArguments': \ - Append '-4fd0l' to refer to 'subscript(something: Int) -> Int { get }'. \ - Append '-757cj' to refer to 'subscript(somethingElse: String) -> Int { get }'. - """) + try assertPathRaisesErrorMessage("/MixedFramework/CollisionsWithDifferentSubscriptArguments/subscript(_:)", in: tree, context: context, expectedErrorMessage: "Reference is ambiguous after '/MixedFramework/CollisionsWithDifferentSubscriptArguments'.") { error in + XCTAssertEqual(error.solutions, [ + .init(summary: "Insert disambiguation suffix for 'subscript(something: Int) -> Int { get }'", replacements: ["-4fd0l"]), + .init(summary: "Insert disambiguation suffix for 'subscript(somethingElse: String) -> Int { get }'", replacements: ["-757cj"]), + ]) + } // typedef NS_OPTIONS(NSInteger, MyObjectiveCOption) { // MyObjectiveCOptionNone = 0, @@ -865,11 +876,12 @@ class PathHierarchyTests: XCTestCase { ("s:5MyKit0A5MyProtocol0Afunc()DefaultImp", "2dxqn"), ("s:5MyKit0A5MyProtocol0Afunc()", "6ijsi"), ]) - try assertPathRaisesErrorMessage("/SideKit/SideProtocol/func()", in: tree, context: context, expectedErrorMessage: """ - Reference is ambiguous after '/SideKit/SideProtocol': \ - Append '-2dxqn' to refer to 'func1()'. \ - Append '-6ijsi' to refer to 'func1()'. - """) // This test data have the same declaration for both symbols. + try assertPathRaisesErrorMessage("/SideKit/SideProtocol/func()", in: tree, context: context, expectedErrorMessage: "Reference is ambiguous after '/SideKit/SideProtocol'.") { error in + XCTAssertEqual(error.solutions, [ + .init(summary: "Insert disambiguation suffix for 'func1()'", replacements: ["-2dxqn"]), + .init(summary: "Insert disambiguation suffix for 'func1()'", replacements: ["-6ijsi"]), + ]) + } // This test data have the same declaration for both symbols. try assertFindsPath("/FillIntroduced/iOSOnlyDeprecated()", in: tree, asSymbolID: "s:14FillIntroduced17iOSOnlyDeprecatedyyF") try assertFindsPath("/FillIntroduced/macCatalystOnlyIntroduced()", in: tree, asSymbolID: "s:14FillIntroduced015macCatalystOnlyB0yyF") @@ -910,12 +922,13 @@ class PathHierarchyTests: XCTestCase { ("c:@E@Foo", "struct"), ("c:MixedLanguageFramework.h@T@Foo", "typealias"), ]) - try assertPathRaisesErrorMessage("MixedLanguageFramework/Foo", in: tree, context: context, expectedErrorMessage: """ - Reference is ambiguous after '/MixedLanguageFramework': \ - Append '-enum' to refer to 'typedef enum Foo : NSString { ... } Foo;'. \ - Append '-struct' to refer to 'struct Foo'. \ - Append '-typealias' to refer to 'typedef enum Foo : NSString { ... } Foo;'. - """) // The 'enum' and 'typealias' symbols have multi-line declarations that are presented on a single line + try assertPathRaisesErrorMessage("MixedLanguageFramework/Foo", in: tree, context: context, expectedErrorMessage: "Reference is ambiguous after '/MixedLanguageFramework'.") { error in + XCTAssertEqual(error.solutions, [ + .init(summary: "Insert disambiguation suffix for 'struct Foo'", replacements: ["-struct"]), + .init(summary: "Insert disambiguation suffix for 'typedef enum Foo : NSString { ... } Foo;'", replacements: ["-enum"]), + .init(summary: "Insert disambiguation suffix for 'typedef enum Foo : NSString { ... } Foo;'", replacements: ["-typealias"]), + ]) + } // The 'enum' and 'typealias' symbols have multi-line declarations that are presented on a single line try assertFindsPath("MixedLanguageFramework/Foo/first", in: tree, asSymbolID: "c:@E@Foo@first") @@ -1282,7 +1295,7 @@ class PathHierarchyTests: XCTestCase { XCTFail("Symbol for \(path.singleQuoted) not found in tree", file: file, line: line) } catch PathHierarchy.Error.partialResult { XCTFail("Symbol for \(path.singleQuoted) not found in tree. Only part of path is found.", file: file, line: line) - } catch PathHierarchy.Error.lookupCollision(_, let collisions) { + } catch PathHierarchy.Error.lookupCollision(_, _, let collisions) { let symbols = collisions.map { $0.node.symbol! } XCTFail("Unexpected collision for \(path.singleQuoted); \(symbols.map { return "\($0.names.title) - \($0.kind.identifier.identifier) - \($0.identifier.precise.stableHashString)"})", file: file, line: line) } @@ -1296,7 +1309,7 @@ class PathHierarchyTests: XCTestCase { // This specific error is expected. } catch PathHierarchy.Error.partialResult { // For the purpose of this assertion, this also counts as "not found". - } catch PathHierarchy.Error.lookupCollision(_, let collisions) { + } catch PathHierarchy.Error.lookupCollision(_, _, let collisions) { let symbols = collisions.map { $0.node.symbol! } XCTFail("Unexpected collision for \(path.singleQuoted); \(symbols.map { return "\($0.names.title) - \($0.kind.identifier.identifier) - \($0.identifier.precise.stableHashString)"})", file: file, line: line) } @@ -1310,7 +1323,7 @@ class PathHierarchyTests: XCTestCase { XCTFail("Symbol for \(path.singleQuoted) not found in tree", file: file, line: line) } catch PathHierarchy.Error.partialResult { XCTFail("Symbol for \(path.singleQuoted) not found in tree. Only part of path is found.", file: file, line: line) - } catch PathHierarchy.Error.lookupCollision(_, let collisions) { + } catch PathHierarchy.Error.lookupCollision(_, _, let collisions) { let sortedCollisions = collisions.sorted(by: \.disambiguation) XCTAssertEqual(sortedCollisions.count, expectedCollisions.count, file: file, line: line) for (actual, expected) in zip(sortedCollisions, expectedCollisions) { @@ -1320,11 +1333,12 @@ class PathHierarchyTests: XCTestCase { } } - private func assertPathRaisesErrorMessage(_ path: String, in tree: PathHierarchy, context: DocumentationContext, expectedErrorMessage: String, file: StaticString = #file, line: UInt = #line) throws { + private func assertPathRaisesErrorMessage(_ path: String, in tree: PathHierarchy, context: DocumentationContext, expectedErrorMessage: String, file: StaticString = #file, line: UInt = #line, _ additionalAssertion: (TopicReferenceResolutionError) -> Void = { _ in }) throws { XCTAssertThrowsError(try tree.findSymbol(path: path), "Finding path \(path) didn't raise an error.",file: file,line: line) { untypedError in let error = untypedError as! PathHierarchy.Error - let errorMessage = error.errorMessage(context: context) - XCTAssertEqual(errorMessage, expectedErrorMessage, file: file, line: line) + let referenceError = error.asTopicReferenceResolutionError(context: context, originalReference: path) + XCTAssertEqual(referenceError.localizedDescription, expectedErrorMessage, file: file, line: line) + additionalAssertion(referenceError) } } @@ -1345,3 +1359,16 @@ extension PathHierarchy { return lookup[id]!.symbol! } } + +private extension TopicReferenceResolutionError { + var solutions: [SimplifiedSolution] { + self.solutions(referenceSourceRange: SourceLocation(line: 0, column: 0, source: nil).. + + ### Near Miss + + - ``otherFunction()`` + - ``/MyKit/MyClas`` + - ``MyKit/MyClas/myFunction()`` + - - ### Ambiguous curation - - - ``init()`` - - ``MyClass/init()-swift.init`` + ### Ambiguous curation + + - ``init()`` + - ``MyClass/init()-swift.init`` + - """ let documentationExtensionURL = url.appendingPathComponent("documentation/myclass.md") @@ -554,12 +562,384 @@ class SymbolTests: XCTestCase { XCTAssertTrue(unresolvedTopicProblems.contains(where: { $0.diagnostic.localizedSummary == "Topic reference 'doc://com.test.external/ExternalPage' couldn't be resolved. No external resolver registered for 'com.test.external'." })) if LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver { - XCTAssertTrue(unresolvedTopicProblems.contains(where: { $0.diagnostic.localizedSummary == "Topic reference 'UnresolvableSymbolLinkInMyClassOverview<>(_:))' couldn't be resolved. Reference at '/MyKit/MyClass' can't resolve 'UnresolvableSymbolLinkInMyClassOverview<>(_:))'. No similar pages. Available children: init(), myFunction()." })) - XCTAssertTrue(unresolvedTopicProblems.contains(where: { $0.diagnostic.localizedSummary == "Topic reference 'UnresolvableClassInMyClassTopicCuration' couldn't be resolved. Reference at '/MyKit/MyClass' can't resolve 'UnresolvableClassInMyClassTopicCuration'. No similar pages. Available children: init(), myFunction()." })) + var problem: Problem + + problem = try XCTUnwrap(unresolvedTopicProblems.first(where: { $0.diagnostic.localizedSummary == "Topic reference 'UnresolvableSymbolLinkInMyClassOverview<>(_:))' couldn't be resolved. Reference at '/MyKit/MyClass' can't resolve 'UnresolvableSymbolLinkInMyClassOverview<>(_:))'. No similar pages." })) + XCTAssertEqual(problem.diagnostic.notes.map(\.message), ["Available children: init(), myFunction()."]) + XCTAssertEqual(problem.possibleSolutions.count, 2) + + + problem = try XCTUnwrap(unresolvedTopicProblems.first(where: { $0.diagnostic.localizedSummary == "Topic reference 'UnresolvableClassInMyClassTopicCuration' couldn't be resolved. Reference at '/MyKit/MyClass' can't resolve 'UnresolvableClassInMyClassTopicCuration'. No similar pages." })) + XCTAssertEqual(problem.diagnostic.notes.map(\.message), ["Available children: init(), myFunction()."]) + XCTAssertEqual(problem.possibleSolutions.count, 2) + + + problem = try XCTUnwrap(unresolvedTopicProblems.first(where: { $0.diagnostic.localizedSummary == "Topic reference 'MyClass/unresolvablePropertyInMyClassTopicCuration' couldn't be resolved. Reference at '/MyKit/MyClass' can't resolve 'unresolvablePropertyInMyClassTopicCuration'. No similar pages." })) + XCTAssertEqual(problem.diagnostic.notes.map(\.message), ["Available children: init(), myFunction()."]) + XCTAssertEqual(problem.possibleSolutions.count, 2) + + + problem = try XCTUnwrap(unresolvedTopicProblems.first(where: { $0.diagnostic.localizedSummary == "Topic reference 'init()' couldn't be resolved. Reference is ambiguous after '/MyKit/MyClass'." })) + XCTAssert(problem.diagnostic.notes.isEmpty) + XCTAssertEqual(problem.possibleSolutions.count, 2) + XCTAssert(problem.possibleSolutions.map(\.replacements.count).allSatisfy { $0 == 1 }) + XCTAssertEqual(problem.possibleSolutions.map { [$0.summary, $0.replacements.first!.replacement] }, [ + ["Insert disambiguation suffix for 'init()'", "-33vaw"], + ["Insert disambiguation suffix for 'init()'", "-3743d"], + ]) + XCTAssertEqual(try problem.possibleSolutions.first!.applyTo(contentsOf: url.appendingPathComponent("documentation/myclass.md")), """ + # ``MyKit/MyClass`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + A cool API to call. + + This overview has an ``UnresolvableSymbolLinkInMyClassOverview<>(_:))``. + + - Parameters: + - name: A parameter + - Returns: Return value + + ## Topics + + ### Unresolvable curation + + - ``UnresolvableClassInMyClassTopicCuration`` + - ``MyClass/unresolvablePropertyInMyClassTopicCuration`` + - + + ### Near Miss + + - ``otherFunction()`` + - ``/MyKit/MyClas`` + - ``MyKit/MyClas/myFunction()`` + - + + ### Ambiguous curation + + - ``init()-33vaw`` + - ``MyClass/init()-swift.init`` + - + """) + + + problem = try XCTUnwrap(unresolvedTopicProblems.first(where: { $0.diagnostic.localizedSummary == "Topic reference 'MyClass/init()-swift.init' couldn't be resolved. Reference is ambiguous after '/MyKit/MyClass'." })) + XCTAssert(problem.diagnostic.notes.isEmpty) + XCTAssertEqual(problem.possibleSolutions.count, 2) + XCTAssert(problem.possibleSolutions.map(\.replacements.count).allSatisfy { $0 == 1 }) + XCTAssertEqual(problem.possibleSolutions.map { [$0.summary, $0.replacements.first!.replacement] }, [ + ["Insert disambiguation suffix for 'init()'", "-33vaw"], + ["Insert disambiguation suffix for 'init()'", "-3743d"], + ]) + XCTAssertEqual(try problem.possibleSolutions.first!.applyTo(contentsOf: url.appendingPathComponent("documentation/myclass.md")), """ + # ``MyKit/MyClass`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + A cool API to call. + + This overview has an ``UnresolvableSymbolLinkInMyClassOverview<>(_:))``. + + - Parameters: + - name: A parameter + - Returns: Return value + + ## Topics + + ### Unresolvable curation + + - ``UnresolvableClassInMyClassTopicCuration`` + - ``MyClass/unresolvablePropertyInMyClassTopicCuration`` + - + + ### Near Miss + + - ``otherFunction()`` + - ``/MyKit/MyClas`` + - ``MyKit/MyClas/myFunction()`` + - + + ### Ambiguous curation + + - ``init()`` + - ``MyClass/init()-33vaw`` + - + """) + + + problem = try XCTUnwrap(unresolvedTopicProblems.first(where: { $0.diagnostic.localizedSummary == "Topic reference 'doc:MyClass/init()-swift.init' couldn't be resolved. Reference is ambiguous after '/MyKit/MyClass'." })) + XCTAssert(problem.diagnostic.notes.isEmpty) + XCTAssertEqual(problem.possibleSolutions.count, 2) + XCTAssert(problem.possibleSolutions.map(\.replacements.count).allSatisfy { $0 == 1 }) + XCTAssertEqual(problem.possibleSolutions.map { [$0.summary, $0.replacements.first!.replacement] }, [ + ["Insert disambiguation suffix for 'init()'", "-33vaw"], + ["Insert disambiguation suffix for 'init()'", "-3743d"], + ]) + XCTAssertEqual(try problem.possibleSolutions.first!.applyTo(contentsOf: url.appendingPathComponent("documentation/myclass.md")), """ + # ``MyKit/MyClass`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + A cool API to call. + + This overview has an ``UnresolvableSymbolLinkInMyClassOverview<>(_:))``. + + - Parameters: + - name: A parameter + - Returns: Return value + + ## Topics + + ### Unresolvable curation + + - ``UnresolvableClassInMyClassTopicCuration`` + - ``MyClass/unresolvablePropertyInMyClassTopicCuration`` + - + + ### Near Miss + + - ``otherFunction()`` + - ``/MyKit/MyClas`` + - ``MyKit/MyClas/myFunction()`` + - + + ### Ambiguous curation + + - ``init()`` + - ``MyClass/init()-swift.init`` + - + """) + + + problem = try XCTUnwrap(unresolvedTopicProblems.first(where: { $0.diagnostic.localizedSummary == "Topic reference 'otherFunction()' couldn't be resolved. Reference at '/MyKit/MyClass' can't resolve 'otherFunction()'. Did you mean myFunction()?" })) + XCTAssertEqual(problem.diagnostic.notes.map(\.message), ["Available children: init(), myFunction()."]) + XCTAssertEqual(problem.possibleSolutions.count, 1) + XCTAssert(problem.possibleSolutions.map(\.replacements.count).allSatisfy { $0 == 1 }) + XCTAssertEqual(problem.possibleSolutions.map { [$0.summary, $0.replacements.first!.replacement] }, [ + ["Correct reference to myFunction().", "myFunction()"], + ]) + XCTAssertEqual(try problem.possibleSolutions.first!.applyTo(contentsOf: url.appendingPathComponent("documentation/myclass.md")), """ + # ``MyKit/MyClass`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + A cool API to call. + + This overview has an ``UnresolvableSymbolLinkInMyClassOverview<>(_:))``. + + - Parameters: + - name: A parameter + - Returns: Return value + + ## Topics + + ### Unresolvable curation + + - ``UnresolvableClassInMyClassTopicCuration`` + - ``MyClass/unresolvablePropertyInMyClassTopicCuration`` + - - XCTAssertTrue(unresolvedTopicProblems.contains(where: { $0.diagnostic.localizedSummary == "Topic reference 'MyClass/unresolvablePropertyInMyClassTopicCuration' couldn't be resolved. Reference at '/MyKit/MyClass' can't resolve 'unresolvablePropertyInMyClassTopicCuration'. No similar pages. Available children: init(), myFunction()." })) - XCTAssertTrue(unresolvedTopicProblems.contains(where: { $0.diagnostic.localizedSummary == "Topic reference 'init()' couldn't be resolved. Reference is ambiguous after '/MyKit/MyClass': Append '-33vaw' to refer to 'init()'. Append '-3743d' to refer to 'init()'." })) - XCTAssertTrue(unresolvedTopicProblems.contains(where: { $0.diagnostic.localizedSummary == "Topic reference 'MyClass/init()-swift.init' couldn't be resolved. Reference is ambiguous after '/MyKit/MyClass': Append '-33vaw' to refer to 'init()'. Append '-3743d' to refer to 'init()'." })) + ### Near Miss + + - ``myFunction()`` + - ``/MyKit/MyClas`` + - ``MyKit/MyClas/myFunction()`` + - + + ### Ambiguous curation + + - ``init()`` + - ``MyClass/init()-swift.init`` + - + """) + + + problem = try XCTUnwrap(unresolvedTopicProblems.first(where: { $0.diagnostic.localizedSummary == "Topic reference '/MyKit/MyClas' couldn't be resolved. Reference at '/MyKit' can't resolve 'MyClas'. Did you mean MyClass?" })) + XCTAssertEqual(problem.diagnostic.notes.map(\.message), ["Available children: Discussion, globalFunction(_:considering:), MyClass, MyProtocol."]) + XCTAssertEqual(problem.possibleSolutions.count, 1) + XCTAssert(problem.possibleSolutions.map(\.replacements.count).allSatisfy { $0 == 1 }) + XCTAssertEqual(problem.possibleSolutions.map { [$0.summary, $0.replacements.first!.replacement] }, [ + ["Correct reference to /MyKit/MyClass.", "MyClass"], + ]) + XCTAssertEqual(try problem.possibleSolutions.first!.applyTo(contentsOf: url.appendingPathComponent("documentation/myclass.md")), """ + # ``MyKit/MyClass`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + A cool API to call. + + This overview has an ``UnresolvableSymbolLinkInMyClassOverview<>(_:))``. + + - Parameters: + - name: A parameter + - Returns: Return value + + ## Topics + + ### Unresolvable curation + + - ``UnresolvableClassInMyClassTopicCuration`` + - ``MyClass/unresolvablePropertyInMyClassTopicCuration`` + - + + ### Near Miss + + - ``otherFunction()`` + - ``/MyKit/MyClass`` + - ``MyKit/MyClas/myFunction()`` + - + + ### Ambiguous curation + + - ``init()`` + - ``MyClass/init()-swift.init`` + - + """) + + + problem = try XCTUnwrap(unresolvedTopicProblems.first(where: { $0.diagnostic.localizedSummary == "Topic reference 'MyKit/MyClas/myFunction()' couldn't be resolved. Reference at '/MyKit' can't resolve 'MyClas/myFunction()'. Did you mean MyClass?" })) + XCTAssertEqual(problem.diagnostic.notes.map(\.message), ["Available children: Discussion, globalFunction(_:considering:), MyClass, MyProtocol."]) + XCTAssertEqual(problem.possibleSolutions.count, 1) + XCTAssert(problem.possibleSolutions.map(\.replacements.count).allSatisfy { $0 == 1 }) + XCTAssertEqual(problem.possibleSolutions.map { [$0.summary, $0.replacements.first!.replacement] }, [ + ["Correct reference to MyKit/MyClass/myFunction().", "MyClass"], + ]) + XCTAssertEqual(try problem.possibleSolutions.first!.applyTo(contentsOf: url.appendingPathComponent("documentation/myclass.md")), """ + # ``MyKit/MyClass`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + A cool API to call. + + This overview has an ``UnresolvableSymbolLinkInMyClassOverview<>(_:))``. + + - Parameters: + - name: A parameter + - Returns: Return value + + ## Topics + + ### Unresolvable curation + + - ``UnresolvableClassInMyClassTopicCuration`` + - ``MyClass/unresolvablePropertyInMyClassTopicCuration`` + - + + ### Near Miss + + - ``otherFunction()`` + - ``/MyKit/MyClas`` + - ``MyKit/MyClass/myFunction()`` + - + + ### Ambiguous curation + + - ``init()`` + - ``MyClass/init()-swift.init`` + - + """) + + + problem = try XCTUnwrap(unresolvedTopicProblems.first(where: { $0.diagnostic.localizedSummary == "Topic reference 'MyKit/MyClas/myFunction()' couldn't be resolved. Reference at '/MyKit' can't resolve 'MyClas/myFunction()'. Did you mean MyClass?" })) + XCTAssertEqual(problem.diagnostic.notes.map(\.message), ["Available children: Discussion, globalFunction(_:considering:), MyClass, MyProtocol."]) + XCTAssertEqual(problem.possibleSolutions.count, 1) + XCTAssert(problem.possibleSolutions.map(\.replacements.count).allSatisfy { $0 == 1 }) + XCTAssertEqual(problem.possibleSolutions.map { [$0.summary, $0.replacements.first!.replacement] }, [ + ["Correct reference to MyKit/MyClass/myFunction().", "MyClass"], + ]) + XCTAssertEqual(try problem.possibleSolutions.first!.applyTo(contentsOf: url.appendingPathComponent("documentation/myclass.md")), """ + # ``MyKit/MyClass`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + A cool API to call. + + This overview has an ``UnresolvableSymbolLinkInMyClassOverview<>(_:))``. + + - Parameters: + - name: A parameter + - Returns: Return value + + ## Topics + + ### Unresolvable curation + + - ``UnresolvableClassInMyClassTopicCuration`` + - ``MyClass/unresolvablePropertyInMyClassTopicCuration`` + - + + ### Near Miss + + - ``otherFunction()`` + - ``/MyKit/MyClas`` + - ``MyKit/MyClass/myFunction()`` + - + + ### Ambiguous curation + + - ``init()`` + - ``MyClass/init()-swift.init`` + - + """) + + + problem = try XCTUnwrap(unresolvedTopicProblems.first(where: { $0.diagnostic.localizedSummary == "Topic reference 'doc:MyKit/MyClas/myFunction()' couldn't be resolved. Reference at '/MyKit' can't resolve 'MyClas/myFunction()'. Did you mean MyClass?" })) + XCTAssertEqual(problem.diagnostic.notes.map(\.message), ["Available children: Discussion, globalFunction(_:considering:), MyClass, MyProtocol."]) + XCTAssertEqual(problem.possibleSolutions.count, 1) + XCTAssert(problem.possibleSolutions.map(\.replacements.count).allSatisfy { $0 == 1 }) + XCTAssertEqual(problem.possibleSolutions.map { [$0.summary, $0.replacements.first!.replacement] }, [ + ["Correct reference to MyKit/MyClass/myFunction().", "MyClass"], + ]) + XCTAssertEqual(try problem.possibleSolutions.first!.applyTo(contentsOf: url.appendingPathComponent("documentation/myclass.md")), """ + # ``MyKit/MyClass`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + A cool API to call. + + This overview has an ``UnresolvableSymbolLinkInMyClassOverview<>(_:))``. + + - Parameters: + - name: A parameter + - Returns: Return value + + ## Topics + + ### Unresolvable curation + + - ``UnresolvableClassInMyClassTopicCuration`` + - ``MyClass/unresolvablePropertyInMyClassTopicCuration`` + - + + ### Near Miss + + - ``otherFunction()`` + - ``/MyKit/MyClas`` + - ``MyKit/MyClas/myFunction()`` + - + + ### Ambiguous curation + + - ``init()`` + - ``MyClass/init()-swift.init`` + - + """) } else { XCTAssertTrue(unresolvedTopicProblems.contains(where: { $0.diagnostic.localizedSummary == "Topic reference 'UnresolvableSymbolLinkInMyClassOverview<>(_:))' couldn't be resolved. No local documentation matches this reference." })) XCTAssertTrue(unresolvedTopicProblems.contains(where: { $0.diagnostic.localizedSummary == "Topic reference 'UnresolvableClassInMyClassTopicCuration' couldn't be resolved. No local documentation matches this reference." })) @@ -570,14 +950,55 @@ class SymbolTests: XCTestCase { } func testUnresolvedReferenceWarningsInDocComment() throws { + let docComment = """ + A cool API to call. + + This overview has an ``UnresolvableSymbolLinkInMyClassOverview``. + + - Parameters: + - name: A parameter + - Returns: Return value + + # Topics + + ## Unresolvable curation + + - ``UnresolvableClassInMyClassTopicCuration`` + - ``MyClass/unresolvablePropertyInMyClassTopicCuration`` + - + """ + let (_, _, context) = try testBundleAndContext(copying: "TestBundle") { url in var graph = try JSONDecoder().decode(SymbolGraph.self, from: Data(contentsOf: url.appendingPathComponent("mykit-iOS.symbols.json"))) let myFunctionUSR = "s:5MyKit0A5ClassC10myFunctionyyF" + + // SymbolKit.SymbolGraph.LineList.SourceRange.Position is indexed from 0, whereas + // (absolute) Markdown.SourceLocations are indexed from 1 + let newDocComment = SymbolGraph.LineList(docComment.components(separatedBy: .newlines).enumerated().map { lineNumber, lineText in + .init(text: lineText, range: .init(start: .init(line: lineNumber, character: 0), end: .init(line: lineNumber, character: lineText.count))) + }) + graph.symbols[myFunctionUSR]?.docComment = newDocComment - let docComment = """ + let newGraphData = try JSONEncoder().encode(graph) + try newGraphData.write(to: url.appendingPathComponent("mykit-iOS.symbols.json")) + } + + let unresolvedTopicProblems = context.problems.filter { $0.diagnostic.identifier == "org.swift.docc.unresolvedTopicReference" } + + if LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver { + var problem: Problem + + problem = try XCTUnwrap(unresolvedTopicProblems.first(where: { $0.diagnostic.localizedSummary == "Topic reference 'UnresolvableSymbolLinkInMyClassOverview' couldn't be resolved. Reference at '/MyKit/MyClass/myFunction()' can't resolve 'UnresolvableSymbolLinkInMyClassOverview'. Did you mean Unresolvable-curation?" })) + XCTAssert(problem.diagnostic.notes.isEmpty) + XCTAssertEqual(problem.possibleSolutions.count, 1) + XCTAssert(problem.possibleSolutions.map(\.replacements.count).allSatisfy { $0 == 1 }) + XCTAssertEqual(problem.possibleSolutions.map { [$0.summary, $0.replacements.first!.replacement] }, [ + ["Correct reference to Unresolvable-curation.", "Unresolvable-curation"], + ]) + XCTAssertEqual(try problem.possibleSolutions.first!.applyTo(docComment), """ A cool API to call. - This overview has an ``UnresolvableSymbolLinkInMyClassOverview``. + This overview has an ``Unresolvable-curation``. - Parameters: - name: A parameter @@ -590,22 +1011,59 @@ class SymbolTests: XCTestCase { - ``UnresolvableClassInMyClassTopicCuration`` - ``MyClass/unresolvablePropertyInMyClassTopicCuration`` - - """ + """) + + problem = try XCTUnwrap(unresolvedTopicProblems.first(where: { $0.diagnostic.localizedSummary == "Topic reference 'UnresolvableClassInMyClassTopicCuration' couldn't be resolved. Reference at '/MyKit/MyClass/myFunction()' can't resolve 'UnresolvableClassInMyClassTopicCuration'. Did you mean Unresolvable-curation?" })) + XCTAssert(problem.diagnostic.notes.isEmpty) + XCTAssertEqual(problem.possibleSolutions.count, 1) + XCTAssert(problem.possibleSolutions.map(\.replacements.count).allSatisfy { $0 == 1 }) + XCTAssertEqual(problem.possibleSolutions.map { [$0.summary, $0.replacements.first!.replacement] }, [ + ["Correct reference to Unresolvable-curation.", "Unresolvable-curation"], + ]) + XCTAssertEqual(try problem.possibleSolutions.first!.applyTo(docComment), """ + A cool API to call. - let position: SymbolGraph.LineList.SourceRange.Position = .init(line: 1, character: 1) - let newDocComment = SymbolGraph.LineList(docComment.components(separatedBy: .newlines).map { .init(text: $0, range: .init(start: position, end: position)) }) - graph.symbols[myFunctionUSR]?.docComment = newDocComment + This overview has an ``UnresolvableSymbolLinkInMyClassOverview``. + + - Parameters: + - name: A parameter + - Returns: Return value + + # Topics + + ## Unresolvable curation + + - ``Unresolvable-curation`` + - ``MyClass/unresolvablePropertyInMyClassTopicCuration`` + - + """) - let newGraphData = try JSONEncoder().encode(graph) - try newGraphData.write(to: url.appendingPathComponent("mykit-iOS.symbols.json")) - } - - let unresolvedTopicProblems = context.problems.filter { $0.diagnostic.identifier == "org.swift.docc.unresolvedTopicReference" } - - if LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver { - XCTAssertTrue(unresolvedTopicProblems.contains(where: { $0.diagnostic.localizedSummary == "Topic reference 'UnresolvableSymbolLinkInMyClassOverview' couldn't be resolved. Reference at '/MyKit/MyClass/myFunction()' can't resolve 'UnresolvableSymbolLinkInMyClassOverview'. Did you mean: Unresolvable-curation?" })) - XCTAssertTrue(unresolvedTopicProblems.contains(where: { $0.diagnostic.localizedSummary == "Topic reference 'UnresolvableClassInMyClassTopicCuration' couldn't be resolved. Reference at '/MyKit/MyClass/myFunction()' can't resolve 'UnresolvableClassInMyClassTopicCuration'. Did you mean: Unresolvable-curation?" })) - XCTAssertTrue(unresolvedTopicProblems.contains(where: { $0.diagnostic.localizedSummary == "Topic reference 'MyClass/unresolvablePropertyInMyClassTopicCuration' couldn't be resolved. Reference at '/MyKit/MyClass' can't resolve 'unresolvablePropertyInMyClassTopicCuration'. No similar pages. Available children: init(), myFunction()." })) + + problem = try XCTUnwrap(unresolvedTopicProblems.first(where: { $0.diagnostic.localizedSummary == "Topic reference 'MyClass/unresolvablePropertyInMyClassTopicCuration' couldn't be resolved. Reference at '/MyKit/MyClass' can't resolve 'unresolvablePropertyInMyClassTopicCuration'. No similar pages." })) + XCTAssert(problem.diagnostic.notes.isEmpty) + XCTAssertEqual(problem.possibleSolutions.count, 2) + XCTAssert(problem.possibleSolutions.map(\.replacements.count).allSatisfy { $0 == 1 }) + XCTAssertEqual(problem.possibleSolutions.map { [$0.summary, $0.replacements.first!.replacement] }, [ + ["Correct reference to MyClass/init().", "init()"], + ["Correct reference to MyClass/myFunction().", "myFunction()"], + ]) + XCTAssertEqual(try problem.possibleSolutions.first!.applyTo(docComment), """ + A cool API to call. + + This overview has an ``UnresolvableSymbolLinkInMyClassOverview``. + + - Parameters: + - name: A parameter + - Returns: Return value + + # Topics + + ## Unresolvable curation + + - ``UnresolvableClassInMyClassTopicCuration`` + - ``MyClass/init()`` + - + """) } else { XCTAssertTrue(unresolvedTopicProblems.contains(where: { $0.diagnostic.localizedSummary == "Topic reference 'UnresolvableSymbolLinkInMyClassOverview' couldn't be resolved. No local documentation matches this reference." })) XCTAssertTrue(unresolvedTopicProblems.contains(where: { $0.diagnostic.localizedSummary == "Topic reference 'UnresolvableClassInMyClassTopicCuration' couldn't be resolved. No local documentation matches this reference." })) @@ -734,3 +1192,47 @@ class SymbolTests: XCTestCase { return (semantic, engine.problems) } } + + +extension Solution { + func applyTo(contentsOf url: URL) throws -> String { + let content = String(data: try Data(contentsOf: url), encoding: .utf8)! + return try self.applyTo(content) + } + + func applyTo(_ content: String) throws -> String { + var content = content + + // We have to make sure we don't change the indices for later replacements while applying + // earlier ones. As long as replacement ranges don't overlap it's enough to apply + // replacements from bottom-most to top-most. + for replacement in self.replacements.sorted(by: \.range.lowerBound).reversed() { + content.replaceSubrange(replacement.range.lowerBound.index(in: content).. String.Index { + var line = 1 + var column = 1 + for index in string.indices { + let character = string[index] + + if line == self.line && column == self.column || line > self.line { + return index + } + + if character.isNewline { + line += 1 + column = 1 + } else { + column += 1 + } + } + + return string.endIndex + } +} diff --git a/Tests/SwiftDocCUtilitiesTests/OutOfProcessReferenceResolverTests.swift b/Tests/SwiftDocCUtilitiesTests/OutOfProcessReferenceResolverTests.swift index 7c5a09351e..ce6e575a68 100644 --- a/Tests/SwiftDocCUtilitiesTests/OutOfProcessReferenceResolverTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/OutOfProcessReferenceResolverTests.swift @@ -448,11 +448,11 @@ class OutOfProcessReferenceResolverTests: XCTestCase { func assertForwardsResolverErrors(resolver: OutOfProcessReferenceResolver) throws { XCTAssertEqual(resolver.bundleIdentifier, "com.test.bundle") let resolverResult = resolver.resolve(.unresolved(UnresolvedTopicReference(topicURL: ValidatedURL(parsingExact: "doc://com.test.bundle/something")!)), sourceLanguage: .swift) - guard case .failure(_, let errorMessage) = resolverResult else { + guard case .failure(_, let error) = resolverResult else { XCTFail("Encountered an unexpected type of error.") return } - XCTAssertEqual(errorMessage, "Some error message.") + XCTAssertEqual(error.localizedDescription, "Some error message.") } func testForwardsResolverErrorsProcess() throws {