diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+TypeSignature.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+TypeSignature.swift index 06d33cdf38..41bb59cac4 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+TypeSignature.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+TypeSignature.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2023-2024 Apple Inc. and the Swift project authors + Copyright (c) 2023-2025 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 @@ -555,6 +555,15 @@ private struct StringScanner: ~Copyable { return remaining[.. Bool) -> Substring? { + guard let beforeIndex = remaining.firstIndex(where: predicate) else { + return nil + } + let index = remaining.index(after: beforeIndex) + defer { remaining = remaining[index...] } + return remaining[.. 0 { - if $0 == ")" { - depth -= 1 - } + else if $0 == ")" { + depth -= 1 + return depth == 0 // stop only if we've reached a balanced number of parenthesis + } + return false // keep scanning + } + + return scan(past: predicate) + } + + mutating func scanValue() -> Substring? { + // The value may contain any number of nested generics. Keep track of the open and close angle brackets while scanning. + var depth = 0 + let predicate: (Character) -> Bool = { + if $0 == "<" { + depth += 1 + return false // keep scanning + } + else if $0 == ">" { + depth -= 1 return false // keep scanning } - return $0 == "," || $0 == ")" + return depth == 0 && ($0 == "," || $0 == ")") } return scan(until: predicate) } diff --git a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift index c7c54f5495..57627d5086 100644 --- a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift @@ -2037,6 +2037,71 @@ class PathHierarchyTests: XCTestCase { try assertFindsPath("doSomething()-9kd0v", in: tree, asSymbolID: "some-function-id-AnyObject") } + func testParameterDisambiguationWithKeyPathType() async throws { + // Create two overloads with different key path parameter types + let parameterTypes: [SymbolGraph.Symbol.DeclarationFragments.Fragment] = [ + // Swift.Int + .init(kind: .typeIdentifier, spelling: "Int", preciseIdentifier: "s:Si"), + // Swift.Bool + .init(kind: .typeIdentifier, spelling: "Bool", preciseIdentifier: "s:Sb"), + ] + + let catalog = Folder(name: "CatalogName.docc", content: [ + JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph(moduleName: "ModuleName", symbols: parameterTypes.map { parameterTypeFragment in + makeSymbol(id: "some-function-id-\(parameterTypeFragment.spelling)-KeyPath", kind: .func, pathComponents: ["doSomething(keyPath:)"], signature: .init( + parameters: [ + // "keyPath: KeyPath" or "keyPath: KeyPath" + .init(name: "keyPath", externalName: nil, declarationFragments: [ + .init(kind: .identifier, spelling: "keyPath", preciseIdentifier: nil), + .init(kind: .text, spelling: ": ", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "KeyPath", preciseIdentifier: "s:s7KeyPathC"), + .init(kind: .text, spelling: "<", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "String", preciseIdentifier: "s:SS"), + .init(kind: .text, spelling: ", ", preciseIdentifier: nil), + parameterTypeFragment, + .init(kind: .text, spelling: ">", preciseIdentifier: nil) + ], children: []) + ], + returns: [ + .init(kind: .text, spelling: "()", preciseIdentifier: nil) // 'Void' in text representation + ] + )) + })), + ]) + let (_, context) = try await loadBundle(catalog: catalog) + let tree = context.linkResolver.localResolver.pathHierarchy + + XCTAssert(context.problems.isEmpty, "Unexpected problems \(context.problems.map(\.diagnostic.summary))") + + let paths = tree.caseInsensitiveDisambiguatedPaths() + + XCTAssertEqual(paths["some-function-id-Int-KeyPath"], "/ModuleName/doSomething(keyPath:)-(KeyPath)") + XCTAssertEqual(paths["some-function-id-Bool-KeyPath"], "/ModuleName/doSomething(keyPath:)-(KeyPath)") + + try assertPathCollision("doSomething(keyPath:)", in: tree, collisions: [ + ("some-function-id-Int-KeyPath", "-(KeyPath)"), + ("some-function-id-Bool-KeyPath", "-(KeyPath)"), + ]) + + try assertPathRaisesErrorMessage("doSomething(keyPath:)", in: tree, context: context, expectedErrorMessage: "'doSomething(keyPath:)' is ambiguous at '/ModuleName'") { error in + XCTAssertEqual(error.solutions.count, 2) + + // These test symbols don't have full declarations. A real solution would display enough information to distinguish these. + XCTAssertEqual(error.solutions.dropFirst(0).first, .init(summary: "Insert '-(KeyPath)' for \n'doSomething(keyPath:)'" , replacements: [("-(KeyPath)", 21, 21)])) + XCTAssertEqual(error.solutions.dropFirst(1).first, .init(summary: "Insert '-(KeyPath)' for \n'doSomething(keyPath:)'" /* the test symbols don't have full declarations */, replacements: [("-(KeyPath)", 21, 21)])) + } + + assertParsedPathComponents("doSomething(keyPath:)-(KeyPath)", [("doSomething(keyPath:)", .typeSignature(parameterTypes: ["KeyPath"], returnTypes: nil))]) + try assertFindsPath("doSomething(keyPath:)-(KeyPath)", in: tree, asSymbolID: "some-function-id-Int-KeyPath") + try assertFindsPath("doSomething(keyPath:)-(KeyPath)->()", in: tree, asSymbolID: "some-function-id-Int-KeyPath") + try assertFindsPath("doSomething(keyPath:)-2zg7h", in: tree, asSymbolID: "some-function-id-Int-KeyPath") + + assertParsedPathComponents("doSomething(keyPath:)-(KeyPath)", [("doSomething(keyPath:)", .typeSignature(parameterTypes: ["KeyPath"], returnTypes: nil))]) + try assertFindsPath("doSomething(keyPath:)-(KeyPath)", in: tree, asSymbolID: "some-function-id-Bool-KeyPath") + try assertFindsPath("doSomething(keyPath:)-(KeyPath)->()", in: tree, asSymbolID: "some-function-id-Bool-KeyPath") + try assertFindsPath("doSomething(keyPath:)-2frrn", in: tree, asSymbolID: "some-function-id-Bool-KeyPath") + } + func testOverloadGroupSymbolsResolveLinksWithoutHash() async throws { enableFeatureFlag(\.isExperimentalOverloadedSymbolPresentationEnabled) @@ -4404,6 +4469,26 @@ class PathHierarchyTests: XCTestCase { } assertParsedPathComponents("operator[]-(std::string&)->std::string&", [("operator[]", .typeSignature(parameterTypes: ["std::string&"], returnTypes: ["std::string&"]))]) + + // Nested generic types + assertParsedPathComponents("functionName-(KeyPath)", [("functionName", .typeSignature(parameterTypes: ["KeyPath"], returnTypes: nil))]) + assertParsedPathComponents("functionName->KeyPath", [("functionName", .typeSignature(parameterTypes: nil, returnTypes: ["KeyPath"]))]) + + assertParsedPathComponents("functionName-(KeyPath,Dictionary)", [("functionName", .typeSignature(parameterTypes: ["KeyPath", "Dictionary"], returnTypes: nil))]) + assertParsedPathComponents("functionName->(KeyPath,Dictionary)", [("functionName", .typeSignature(parameterTypes: nil, returnTypes: ["KeyPath", "Dictionary"]))]) + + assertParsedPathComponents("functionName-(KeyPath>)", [("functionName", .typeSignature(parameterTypes: ["KeyPath>"], returnTypes: nil))]) + assertParsedPathComponents("functionName->KeyPath>", [("functionName", .typeSignature(parameterTypes: nil, returnTypes: ["KeyPath>"]))]) + + assertParsedPathComponents("functionName-(KeyPath,Dictionary>)", [("functionName", .typeSignature(parameterTypes: ["KeyPath,Dictionary>"], returnTypes: nil))]) + assertParsedPathComponents("functionName->KeyPath,Dictionary>", [("functionName", .typeSignature(parameterTypes: nil, returnTypes: ["KeyPath,Dictionary>"]))]) + + // Nested generics and tuple types + assertParsedPathComponents( "functionName-(A,(D,H<(I,J),(K,L)>),M,R),S>)", [("functionName", .typeSignature(parameterTypes: ["A", "(D,H<(I,J),(K,L)>)", "M,R),S>"], returnTypes: nil))]) + assertParsedPathComponents("functionName->(A,(D,H<(I,J),(K,L)>),M,R),S>)", [("functionName", .typeSignature(parameterTypes: nil, returnTypes: ["A", "(D,H<(I,J),(K,L)>)", "M,R),S>"]))]) + // With special characters + assertParsedPathComponents( "functionName-(Å<𝔹,©>,(Δ<∃,⨍,𝄞>,ℌ<(𝓲,ⅉ),(🄺,ƛ)>),𝔐<𝚗,(Ω<π,Ⓠ>,℟),𝔖>)", [("functionName", .typeSignature(parameterTypes: ["Å<𝔹,©>", "(Δ<∃,⨍,𝄞>,ℌ<(𝓲,ⅉ),(🄺,ƛ)>)", "𝔐<𝚗,(Ω<π,Ⓠ>,℟),𝔖>"], returnTypes: nil))]) + assertParsedPathComponents("functionName->(Å<𝔹,©>,(Δ<∃,⨍,𝄞>,ℌ<(𝓲,ⅉ),(🄺,ƛ)>),𝔐<𝚗,(Ω<π,Ⓠ>,℟),𝔖>)", [("functionName", .typeSignature(parameterTypes: nil, returnTypes: ["Å<𝔹,©>", "(Δ<∃,⨍,𝄞>,ℌ<(𝓲,ⅉ),(🄺,ƛ)>)", "𝔐<𝚗,(Ω<π,Ⓠ>,℟),𝔖>"]))]) } func testResolveExternalLinkFromTechnologyRoot() async throws {