diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Error.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Error.swift index 9f85fc1a2c..67851a2126 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Error.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Error.swift @@ -223,13 +223,27 @@ extension PathHierarchy.Error { case .lookupCollision(partialResult: let partialResult, remaining: let remaining, collisions: let collisions): let nextPathComponent = remaining.first! - let (pathPrefix, _, solutions) = makeCollisionSolutions(from: collisions, nextPathComponent: nextPathComponent, partialResultPrefix: partialResult.pathPrefix) - + let (pathPrefix, foundDisambiguation, solutions) = makeCollisionSolutions( + from: collisions, + nextPathComponent: nextPathComponent, + partialResultPrefix: partialResult.pathPrefix) + + let rangeAdjustment: SourceRange + if !foundDisambiguation.isEmpty { + rangeAdjustment = .makeRelativeRange( + startColumn: pathPrefix.count - foundDisambiguation.count, + length: foundDisambiguation.count) + } else { + rangeAdjustment = .makeRelativeRange( + startColumn: pathPrefix.count - nextPathComponent.full.count, + length: nextPathComponent.full.count) + } + return TopicReferenceResolutionErrorInfo(""" \(nextPathComponent.full.singleQuoted) is ambiguous at \(partialResult.node.pathWithoutDisambiguation().singleQuoted) """, solutions: solutions, - rangeAdjustment: .makeRelativeRange(startColumn: pathPrefix.count - nextPathComponent.full.count, length: nextPathComponent.full.count) + rangeAdjustment: rangeAdjustment ) } } diff --git a/Sources/SwiftDocC/Semantics/ReferenceResolver.swift b/Sources/SwiftDocC/Semantics/ReferenceResolver.swift index d3498fb041..9703463ad0 100644 --- a/Sources/SwiftDocC/Semantics/ReferenceResolver.swift +++ b/Sources/SwiftDocC/Semantics/ReferenceResolver.swift @@ -44,6 +44,12 @@ func unresolvedReferenceProblem(source: URL?, range: SourceRange?, severity: Dia let diagnosticRange: SourceRange? if var rangeAdjustment = errorInfo.rangeAdjustment, let referenceSourceRange { rangeAdjustment.offsetWithRange(referenceSourceRange) + assert(rangeAdjustment.lowerBound.column >= 0, """ + Unresolved topic reference range adjustment created range with negative column. + Source: \(source?.absoluteString ?? "nil") + Range: \(rangeAdjustment.lowerBound.description):\(rangeAdjustment.upperBound.description) + Summary: \(errorInfo.message) + """) diagnosticRange = rangeAdjustment } else { diagnosticRange = referenceSourceRange diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift index 148b37fa91..b9e67fcb4c 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift @@ -5266,7 +5266,165 @@ let expected = """ } } } - + + func testContextDiagnosesInsufficientDisambiguationWithCorrectRange() throws { + // This test deliberately does not turn on the overloads feature + // to ensure the symbol link below does not accidentally resolve correctly. + for symbolKindID in SymbolGraph.Symbol.KindIdentifier.allCases where !symbolKindID.isOverloadableKind { + // Generate a 4 symbols with the same name for every non overloadable symbol kind + let symbols: [SymbolGraph.Symbol] = [ + makeSymbol(id: "first-\(symbolKindID.identifier)-id", kind: symbolKindID, pathComponents: ["SymbolName"]), + makeSymbol(id: "second-\(symbolKindID.identifier)-id", kind: symbolKindID, pathComponents: ["SymbolName"]), + makeSymbol(id: "third-\(symbolKindID.identifier)-id", kind: symbolKindID, pathComponents: ["SymbolName"]), + makeSymbol(id: "fourth-\(symbolKindID.identifier)-id", kind: symbolKindID, pathComponents: ["SymbolName"]), + ] + + let catalog = + Folder(name: "unit-test.docc", content: [ + JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph( + moduleName: "ModuleName", + symbols: symbols + )), + + TextFile(name: "ModuleName.md", utf8Content: """ + # ``ModuleName`` + + This is a test file for ModuleName. + + ## Topics + + - ``SymbolName-\(symbolKindID.identifier)`` + """) + ]) + + let (_, context) = try loadBundle(catalog: catalog) + + let problems = context.problems.sorted(by: \.diagnostic.summary) + XCTAssertEqual(problems.count, 1) + + let problem = try XCTUnwrap(problems.first) + + XCTAssertEqual(problem.diagnostic.summary, "'SymbolName-\(symbolKindID.identifier)' is ambiguous at '/ModuleName'") + + XCTAssertEqual(problem.possibleSolutions.count, 4) + + for solution in problem.possibleSolutions { + XCTAssertEqual(solution.replacements.count, 1) + let replacement = try XCTUnwrap(solution.replacements.first) + + XCTAssertEqual(replacement.range.lowerBound, .init(line: 7, column: 15, source: nil)) + XCTAssertEqual( + replacement.range.upperBound, + .init(line: 7, column: 16 + symbolKindID.identifier.count, source: nil) + ) + } + } + } + + func testContextDiagnosesIncorrectDisambiguationWithCorrectRange() throws { + // This test deliberately does not turn on the overloads feature + // to ensure the symbol link below does not accidentally resolve correctly. + for symbolKindID in SymbolGraph.Symbol.KindIdentifier.allCases where !symbolKindID.isOverloadableKind { + // Generate a 4 symbols with the same name for every non overloadable symbol kind + let symbols: [SymbolGraph.Symbol] = [ + makeSymbol(id: "first-\(symbolKindID.identifier)-id", kind: symbolKindID, pathComponents: ["SymbolName"]), + makeSymbol(id: "second-\(symbolKindID.identifier)-id", kind: symbolKindID, pathComponents: ["SymbolName"]), + makeSymbol(id: "third-\(symbolKindID.identifier)-id", kind: symbolKindID, pathComponents: ["SymbolName"]), + makeSymbol(id: "fourth-\(symbolKindID.identifier)-id", kind: symbolKindID, pathComponents: ["SymbolName"]), + ] + + let catalog = + Folder(name: "unit-test.docc", content: [ + JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph( + moduleName: "ModuleName", + symbols: symbols + )), + + TextFile(name: "ModuleName.md", utf8Content: """ + # ``ModuleName`` + + This is a test file for ModuleName. + + ## Topics + + - ``SymbolName-abc123`` + """) + ]) + + let (_, context) = try loadBundle(catalog: catalog) + + let problems = context.problems.sorted(by: \.diagnostic.summary) + XCTAssertEqual(problems.count, 1) + + let problem = try XCTUnwrap(problems.first) + + XCTAssertEqual(problem.diagnostic.summary, "'abc123' isn't a disambiguation for 'SymbolName' at '/ModuleName'") + + XCTAssertEqual(problem.possibleSolutions.count, 4) + + for solution in problem.possibleSolutions { + XCTAssertEqual(solution.replacements.count, 1) + let replacement = try XCTUnwrap(solution.replacements.first) + + // "Replace '-abc123' with '-(hash)'" where 'abc123' is at 7:15-7:22 + XCTAssertEqual(replacement.range.lowerBound, .init(line: 7, column: 15, source: nil)) + XCTAssertEqual(replacement.range.upperBound, .init(line: 7, column: 22, source: nil)) + } + } + } + + func testContextDiagnosesIncorrectSymbolNameWithCorrectRange() throws { + // This test deliberately does not turn on the overloads feature + // to ensure the symbol link below does not accidentally resolve correctly. + for symbolKindID in SymbolGraph.Symbol.KindIdentifier.allCases where !symbolKindID.isOverloadableKind { + // Generate a 4 symbols with the same name for every non overloadable symbol kind + let symbols: [SymbolGraph.Symbol] = [ + makeSymbol(id: "first-\(symbolKindID.identifier)-id", kind: symbolKindID, pathComponents: ["SymbolName"]), + makeSymbol(id: "second-\(symbolKindID.identifier)-id", kind: symbolKindID, pathComponents: ["SymbolName"]), + makeSymbol(id: "third-\(symbolKindID.identifier)-id", kind: symbolKindID, pathComponents: ["SymbolName"]), + makeSymbol(id: "fourth-\(symbolKindID.identifier)-id", kind: symbolKindID, pathComponents: ["SymbolName"]), + ] + + let catalog = + Folder(name: "unit-test.docc", content: [ + JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph( + moduleName: "ModuleName", + symbols: symbols + )), + + TextFile(name: "ModuleName.md", utf8Content: """ + # ``ModuleName`` + + This is a test file for ModuleName. + + ## Topics + + - ``Symbol`` + """) + ]) + + let (_, context) = try loadBundle(catalog: catalog) + + let problems = context.problems.sorted(by: \.diagnostic.summary) + XCTAssertEqual(problems.count, 1) + + let problem = try XCTUnwrap(problems.first) + + XCTAssertEqual(problem.diagnostic.summary, "'Symbol' doesn't exist at '/ModuleName'") + + XCTAssertEqual(problem.possibleSolutions.count, 1) + let solution = try XCTUnwrap(problem.possibleSolutions.first) + + XCTAssertEqual(solution.summary, "Replace 'Symbol' with 'SymbolName'") + + XCTAssertEqual(solution.replacements.count, 1) + let replacement = try XCTUnwrap(solution.replacements.first) + + XCTAssertEqual(replacement.range.lowerBound, .init(line: 7, column: 5, source: nil)) + XCTAssertEqual(replacement.range.upperBound, .init(line: 7, column: 11, source: nil)) + } + } + func testResolveExternalLinkFromTechnologyRoot() throws { enableFeatureFlag(\.isExperimentalLinkHierarchySerializationEnabled)