From d95e720480bfdb46d4efcfb9042dfa2f483a6f6f Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Mon, 21 Jul 2025 10:55:40 +0100 Subject: [PATCH 1/3] Add mapping from documentation kind to symbol kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is a mapping from `SymbolGraph.Symbol.KindIdentifier` to `DocumentationNode.Kind`, but not the other way around; this commit adds one. For documentation kinds which both map to the same symbol kind, this has been expressed as: - both documentation kinds are converted to the same symbol kind in `symbolKind(for:)`. - a specific documentation kind is chosen as the default mapping in `kind(forKind:)` For example, for `DocumentationNode.Kind. typeConstant` [1]: - both `symbolKind(for: .typeConstant)` and `symbolKind(for: .typeProperty)` return `.typeProperty` - `kind(forKind: .typeProperty)` returns `.typeProperty` This function will be used to map and external entity's documentation kind to a symbol kind, so that that information can be populated as part of the navigator. ## Verification: This has been verified by testing the round trip conversion of all symbol kinds, and all documentation kinds which are symbols. [1]: https://github.com/swiftlang/swift-docc/blob/65aaf926ec079ddbd40f29540d4180a70af99e5e/Sources/SwiftDocC/Model/Kind.swift#L151 --- .../SwiftDocC/Model/DocumentationNode.swift | 50 +++++++++++++++++++ .../Model/DocumentationNodeTests.swift | 35 +++++++++++++ 2 files changed, 85 insertions(+) diff --git a/Sources/SwiftDocC/Model/DocumentationNode.swift b/Sources/SwiftDocC/Model/DocumentationNode.swift index 3aca3e3e2..803ddb776 100644 --- a/Sources/SwiftDocC/Model/DocumentationNode.swift +++ b/Sources/SwiftDocC/Model/DocumentationNode.swift @@ -674,6 +674,7 @@ public struct DocumentationNode { case .union: return .union case .`var`: return .globalVariable case .module: return .module + case .extension: return .extension case .extendedModule: return .extendedModule case .extendedStructure: return .extendedStructure case .extendedClass: return .extendedClass @@ -684,6 +685,55 @@ public struct DocumentationNode { } } + /// Returns a symbol kind for the given documentation node. + /// - Parameter symbol: A documentation node kind. + /// - Returns: A symbol graph symbol. + static func symbolKind(for kind: Kind) -> SymbolGraph.Symbol.KindIdentifier? { + switch kind { + case .associatedType: return .`associatedtype` + case .class: return .`class` + case .deinitializer: return .`deinit` + case .dictionary, .object: return .dictionary + case .dictionaryKey: return .dictionaryKey + case .enumeration: return .`enum` + case .enumerationCase: return .`case` + case .function: return .`func` + case .httpRequest: return .httpRequest + case .httpParameter: return .httpParameter + case .httpBody: return .httpBody + case .httpResponse: return .httpResponse + case .operator: return .`operator` + case .initializer: return .`init` + case .instanceVariable: return .ivar + case .macro: return .macro + case .instanceMethod: return .`method` + case .namespace: return .namespace + case .instanceProperty: return .`property` + case .protocol: return .`protocol` + case .snippet: return .snippet + case .structure: return .`struct` + case .instanceSubscript: return .`subscript` + case .typeMethod: return .`typeMethod` + case .typeProperty, .typeConstant: return .`typeProperty` + case .typeSubscript: return .`typeSubscript` + case .typeAlias, .typeDef: return .`typealias` + case .union: return .union + case .globalVariable, .localVariable: return .`var` + case .module: return .module + case .extension: return .extension + case .extendedModule: return .extendedModule + case .extendedStructure: return .extendedStructure + case .extendedClass: return .extendedClass + case .extendedEnumeration: return .extendedEnumeration + case .extendedProtocol: return .extendedProtocol + case .unknownExtendedType: return .unknownExtendedType + default: + // For non-symbol kinds (like .article, .tutorial, etc.), + // return nil since these don't have corresponding SymbolGraph.Symbol.KindIdentifier values + return nil + } + } + /// Initializes a documentation node to represent a symbol from a symbol graph. /// /// - Parameters: diff --git a/Tests/SwiftDocCTests/Model/DocumentationNodeTests.swift b/Tests/SwiftDocCTests/Model/DocumentationNodeTests.swift index 9b371d23e..5f17246fd 100644 --- a/Tests/SwiftDocCTests/Model/DocumentationNodeTests.swift +++ b/Tests/SwiftDocCTests/Model/DocumentationNodeTests.swift @@ -11,6 +11,7 @@ import Foundation import Markdown @testable import SwiftDocC +import SymbolKit import XCTest class DocumentationNodeTests: XCTestCase { @@ -41,4 +42,38 @@ class DocumentationNodeTests: XCTestCase { XCTAssertEqual(anchorSection.reference, node.reference.withFragment(expectedTitle)) } } + + func testDocumentationKindToSymbolKindMapping() throws { + // Testing all symbol kinds map to a documentation kind + for symbolKind in SymbolGraph.Symbol.KindIdentifier.allCases { + let documentationKind = DocumentationNode.kind(forKind: symbolKind) + guard documentationKind != .unknown else { + continue + } + + let roundtrippedSymbolKind = DocumentationNode.symbolKind(for: documentationKind) + XCTAssertEqual(symbolKind, roundtrippedSymbolKind) + } + + // Testing that documentation kinds correctly map to a symbol kind + // Sometimes there are multiple mappings from DocumentationKind -> SymbolKind, exclude those here and test them separately + let documentationKinds = DocumentationNode.Kind.allKnownValues + .filter({ ![.localVariable, .typeDef, .typeConstant, .`keyword`, .tag, .object].contains($0) }) + for documentationKind in documentationKinds { + let symbolKind = DocumentationNode.symbolKind(for: documentationKind) + if documentationKind.isSymbol { + let symbolKind = try XCTUnwrap(DocumentationNode.symbolKind(for: documentationKind), "Expected a symbol kind equivalent for \(documentationKind)") + let rountrippedDocumentationKind = DocumentationNode.kind(forKind: symbolKind) + XCTAssertEqual(documentationKind, rountrippedDocumentationKind) + } else { + XCTAssertNil(symbolKind) + } + } + + // Test the exception documentation kinds + XCTAssertEqual(DocumentationNode.symbolKind(for: .localVariable), .var) + XCTAssertEqual(DocumentationNode.symbolKind(for: .typeDef), .typealias) + XCTAssertEqual(DocumentationNode.symbolKind(for: .typeConstant), .typeProperty) + XCTAssertEqual(DocumentationNode.symbolKind(for: .object), .dictionary) + } } From 7a3603bb65db67789afb925828110f77aab4c55a Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Mon, 21 Jul 2025 12:55:39 +0100 Subject: [PATCH 2/3] Capture documentation kind as part of external entity The documentation kind is captured as part of the `OutOfProcessReferenceResolver. ResolvedInformation` [1], but it is never propagated upwards to `LinkResolver.ExternalEntity`. We need access to the documentation kind in order to resolve the symbol kind as part of computing the navigator title. [2] [1]: https://github.com/swiftlang/swift-docc/blob/65aaf926ec079ddbd40f29540d4180a70af99e5e/Sources/SwiftDocC/Infrastructure/External%20Data/OutOfProcessReferenceResolver.swift#L564-L565 [2]: https://github.com/swiftlang/swift-docc/blob/65aaf926ec079ddbd40f29540d4180a70af99e5e/Sources/SwiftDocC/Indexing/Navigator/RenderNode%2BNavigatorIndex.swift#L163 --- .../External Data/OutOfProcessReferenceResolver.swift | 7 ++++++- .../ExternalPathHierarchyResolver.swift | 3 ++- .../Infrastructure/Link Resolution/LinkResolver.swift | 10 ++++++++-- .../Benchmark/ExternalTopicsHashTests.swift | 3 ++- .../ExternalReferenceResolverTests.swift | 6 ++++-- .../TestExternalReferenceResolvers.swift | 3 ++- Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift | 6 ++++-- .../OutOfProcessReferenceResolverTests.swift | 2 ++ 8 files changed, 30 insertions(+), 10 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver.swift b/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver.swift index fa3666edd..51203e1a4 100644 --- a/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver.swift @@ -194,7 +194,12 @@ public class OutOfProcessReferenceResolver: ExternalDocumentationSource, GlobalE imageReferences: (resolvedInformation.references ?? []).compactMap { $0 as? ImageReference } ) - return LinkResolver.ExternalEntity(topicRenderReference: renderReference, renderReferenceDependencies: dependencies, sourceLanguages: resolvedInformation.availableLanguages) + return LinkResolver.ExternalEntity( + topicRenderReference: renderReference, + renderReferenceDependencies: dependencies, + sourceLanguages: resolvedInformation.availableLanguages, + documentationKind: resolvedInformation.kind + ) } // MARK: Implementation diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift index 0d145a597..0b0c4391d 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift @@ -109,7 +109,8 @@ final class ExternalPathHierarchyResolver { return .init( topicRenderReference: resolvedInformation.topicRenderReference(), renderReferenceDependencies: dependencies, - sourceLanguages: resolvedInformation.availableLanguages + sourceLanguages: resolvedInformation.availableLanguages, + documentationKind: resolvedInformation.kind ) } diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver.swift index 0ec34d9b4..10b26c4d4 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver.swift @@ -48,11 +48,13 @@ public class LinkResolver { /// - topicRenderReference: The render reference for this external topic. /// - renderReferenceDependencies: Any dependencies for the render reference. /// - sourceLanguages: The different source languages for which this page is available. + /// - documentationKind: The kind of external content that's being referenced. @_spi(ExternalLinks) - public init(topicRenderReference: TopicRenderReference, renderReferenceDependencies: RenderReferenceDependencies, sourceLanguages: Set) { + public init(topicRenderReference: TopicRenderReference, renderReferenceDependencies: RenderReferenceDependencies, sourceLanguages: Set, documentationKind: DocumentationNode.Kind) { self.topicRenderReference = topicRenderReference self.renderReferenceDependencies = renderReferenceDependencies self.sourceLanguages = sourceLanguages + self.documentationKind = documentationKind } /// The render reference for this external topic. @@ -63,7 +65,11 @@ public class LinkResolver { var renderReferenceDependencies: RenderReferenceDependencies /// The different source languages for which this page is available. var sourceLanguages: Set - + /// The kind of external content that's being referenced. + /// + /// For example, the navigator requires specific knowledge about what type of external symbol is being linked to. + var documentationKind: DocumentationNode.Kind + /// Creates a pre-render new topic content value to be added to a render context's reference store. func topicContent() -> RenderReferenceStore.TopicContent { return .init( diff --git a/Tests/SwiftDocCTests/Benchmark/ExternalTopicsHashTests.swift b/Tests/SwiftDocCTests/Benchmark/ExternalTopicsHashTests.swift index 20c36ce3f..d548f725d 100644 --- a/Tests/SwiftDocCTests/Benchmark/ExternalTopicsHashTests.swift +++ b/Tests/SwiftDocCTests/Benchmark/ExternalTopicsHashTests.swift @@ -31,7 +31,8 @@ class ExternalTopicsGraphHashTests: XCTestCase { estimatedTime: nil ), renderReferenceDependencies: .init(), - sourceLanguages: [.swift] + sourceLanguages: [.swift], + documentationKind: .class ) return (reference, entity) } diff --git a/Tests/SwiftDocCTests/Infrastructure/ExternalReferenceResolverTests.swift b/Tests/SwiftDocCTests/Infrastructure/ExternalReferenceResolverTests.swift index e5ed51ddb..8ac864541 100644 --- a/Tests/SwiftDocCTests/Infrastructure/ExternalReferenceResolverTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/ExternalReferenceResolverTests.swift @@ -52,7 +52,8 @@ class ExternalReferenceResolverTests: XCTestCase { } ), renderReferenceDependencies: RenderReferenceDependencies(), - sourceLanguages: [resolvedEntityLanguage] + sourceLanguages: [resolvedEntityLanguage], + documentationKind: resolvedEntityKind ) } } @@ -707,7 +708,8 @@ class ExternalReferenceResolverTests: XCTestCase { estimatedTime: nil ), renderReferenceDependencies: RenderReferenceDependencies(), - sourceLanguages: [.swift] + sourceLanguages: [.swift], + documentationKind: .instanceProperty ) } } diff --git a/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift b/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift index 129d86633..45d0f011f 100644 --- a/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift +++ b/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift @@ -108,7 +108,8 @@ class TestMultiResultExternalReferenceResolver: ExternalDocumentationSource { images: entityInfo.topicImages?.map(\.0) ?? [] ), renderReferenceDependencies: dependencies, - sourceLanguages: [entityInfo.language] + sourceLanguages: [entityInfo.language], + documentationKind: entityInfo.kind ) } } diff --git a/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift b/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift index cc1d497b5..4ccaf7809 100644 --- a/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift +++ b/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift @@ -1188,7 +1188,8 @@ class SemaToRenderNodeTests: XCTestCase { estimatedTime: nil ), renderReferenceDependencies: .init(), - sourceLanguages: [.objectiveC] + sourceLanguages: [.objectiveC], + documentationKind: .class ) return (reference, entity) } @@ -1218,7 +1219,8 @@ class SemaToRenderNodeTests: XCTestCase { estimatedTime: nil ), renderReferenceDependencies: .init(), - sourceLanguages: [.swift] + sourceLanguages: [.swift], + documentationKind: .collection ) } } diff --git a/Tests/SwiftDocCUtilitiesTests/OutOfProcessReferenceResolverTests.swift b/Tests/SwiftDocCUtilitiesTests/OutOfProcessReferenceResolverTests.swift index d96a0216f..65bf572de 100644 --- a/Tests/SwiftDocCUtilitiesTests/OutOfProcessReferenceResolverTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/OutOfProcessReferenceResolverTests.swift @@ -133,6 +133,8 @@ class OutOfProcessReferenceResolverTests: XCTestCase { } else { XCTFail("Unexpected fragments variant patch") } + + XCTAssertEqual(entity.documentationKind, .init(name: "Kind Name", id: "com.test.kind.id", isSymbol: true)) } func testResolvingTopicLinkProcess() throws { From 8173968cf05c6114487125c72a9b9e97a9d85987 Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:00:05 +0100 Subject: [PATCH 3/3] Propagate symbol kind to navigator for external nodes Derives the symbol kind from the documentation kind of the external render node. Also updates how the symbol kind is propagated to the navigator metadata, by using `.renderingIdentifier` over `.identifier`, to match the behaviour of local nodes [1]. [1]: https://github.com/swiftlang/swift-docc/blob/65aaf926ec079ddbd40f29540d4180a70af99e5e/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift#L1300 --- .../Link Resolution/LinkResolver+NavigatorIndex.swift | 9 +++++---- .../Indexing/ExternalRenderNodeTests.swift | 11 ++++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift index c23a850f7..5b6d140a3 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift @@ -39,9 +39,10 @@ package struct ExternalRenderNode { } /// The symbol kind of this documentation node. + /// + /// This value is `nil` if the referenced page is not a symbol. var symbolKind: SymbolGraph.Symbol.KindIdentifier? { - // Symbol kind information is not available for external entities - return nil + DocumentationNode.symbolKind(for: externalEntity.documentationKind) } /// The additional "role" assigned to the symbol, if any @@ -116,7 +117,7 @@ struct NavigatorExternalRenderNode: NavigatorIndexableRenderNodeRepresentation { navigatorTitle: renderNode.navigatorTitleVariants.value(for: traits), externalID: renderNode.externalIdentifier.identifier, role: renderNode.role, - symbolKind: renderNode.symbolKind?.identifier, + symbolKind: renderNode.symbolKind?.renderingIdentifier, images: renderNode.images ) } @@ -145,4 +146,4 @@ struct ExternalRenderNodeMetadataRepresentation: NavigatorIndexableRenderMetadat var fragments: [DeclarationRenderSection.Token]? = nil var roleHeading: String? = nil var platforms: [AvailabilityRenderItem]? = nil -} \ No newline at end of file +} diff --git a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift index 43243e342..a71d0924a 100644 --- a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift +++ b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift @@ -92,7 +92,7 @@ class ExternalRenderNodeTests: XCTestCase { XCTAssertEqual(externalRenderNodes[1].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/objCSymbol") XCTAssertEqual(externalRenderNodes[1].kind, .symbol) - XCTAssertEqual(externalRenderNodes[1].symbolKind, nil) + XCTAssertEqual(externalRenderNodes[1].symbolKind, .func) XCTAssertEqual(externalRenderNodes[1].role, "symbol") XCTAssertEqual(externalRenderNodes[1].externalIdentifier.identifier, "doc://com.test.external/path/to/external/objCSymbol") @@ -104,7 +104,7 @@ class ExternalRenderNodeTests: XCTestCase { XCTAssertEqual(externalRenderNodes[3].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/swiftSymbol") XCTAssertEqual(externalRenderNodes[3].kind, .symbol) - XCTAssertEqual(externalRenderNodes[3].symbolKind, nil) + XCTAssertEqual(externalRenderNodes[3].symbolKind, .class) XCTAssertEqual(externalRenderNodes[3].role, "symbol") XCTAssertEqual(externalRenderNodes[3].externalIdentifier.identifier, "doc://com.test.external/path/to/external/swiftSymbol") } @@ -135,7 +135,8 @@ class ExternalRenderNodeTests: XCTestCase { navigatorTitleVariants: .init(defaultValue: navigatorTitle, objectiveCValue: occNavigatorTitle) ), renderReferenceDependencies: .init(), - sourceLanguages: [SourceLanguage(name: "swift"), SourceLanguage(name: "objc")]) + sourceLanguages: [SourceLanguage(name: "swift"), SourceLanguage(name: "objc")], + documentationKind: .function) let externalRenderNode = ExternalRenderNode( externalEntity: externalEntity, bundleIdentifier: "com.test.external" @@ -208,6 +209,8 @@ class ExternalRenderNodeTests: XCTestCase { XCTAssertEqual(occExternalNodes.map(\.title), ["ObjCArticle", "ObjCSymbol"]) XCTAssert(swiftExternalNodes.allSatisfy(\.isExternal)) XCTAssert(occExternalNodes.allSatisfy(\.isExternal)) + XCTAssertEqual(swiftExternalNodes.map(\.type), ["article", "class"]) + XCTAssertEqual(occExternalNodes.map(\.type), ["article", "func"]) } func testNavigatorWithExternalNodesOnlyAddsCuratedNodesToNavigator() throws { @@ -268,5 +271,7 @@ class ExternalRenderNodeTests: XCTestCase { XCTAssertEqual(occExternalNodes.map(\.title), ["ObjCSymbol"]) XCTAssert(swiftExternalNodes.allSatisfy(\.isExternal)) XCTAssert(occExternalNodes.allSatisfy(\.isExternal)) + XCTAssertEqual(swiftExternalNodes.map(\.type), ["article"]) + XCTAssertEqual(occExternalNodes.map(\.type), ["func"]) } }