From 77d5b38d326dca2b1b4b016604330d4cf23a8aac Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:12:52 +0000 Subject: [PATCH] Render alternate representations as node variants When rendering the variants of a node, use the topic references from the `@AlternateRepresentation` directives to populate more variants. There is no need to report diagnostics as they would have been reported during bundle registration. Link resolution would have already been performed at that point. Unresolved topic references are ignored, but all resolved references are added as variants. If there are multiple symbols per variant, Swift-DocC-Render prefers the first one that was added, which will always be the current node's symbol. There should be no breakage and change of behaviour for anyone not using `@AlternateRepresentation`, and the current symbol's variants will always be preferred over any other. --- .../Rendering/RenderNodeTranslator.swift | 48 ++++++++--- .../Rendering/RenderNodeTranslatorTests.swift | 80 +++++++++++++++++++ 2 files changed, 115 insertions(+), 13 deletions(-) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift index adad3d7d5..3dd473b1a 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift @@ -1852,23 +1852,45 @@ public struct RenderNodeTranslator: SemanticVisitor { private func variants(for documentationNode: DocumentationNode) -> [RenderNode.Variant] { let generator = PresentationURLGenerator(context: context, baseURL: bundle.baseURL) - return documentationNode.availableSourceLanguages - .sorted(by: { language1, language2 in + var allVariants: [SourceLanguage: ResolvedTopicReference] = documentationNode.availableSourceLanguages.reduce(into: [:]) { partialResult, language in + partialResult[language] = identifier + } + + // Apply alternate representations + if let alternateRepresentations = documentationNode.metadata?.alternateRepresentations { + for alternateRepresentation in alternateRepresentations { + // Only alternate representations which were able to be resolved to a reference should be included as an alternate representation. + // Unresolved alternate representations can be ignored, as they would have been reported during link resolution. + guard case .resolved(.success(let alternateRepresentationReference)) = alternateRepresentation.reference else { + continue + } + + // Add the language representations of the alternate symbol as additional variants for the current symbol. + // Symbols can only specify custom alternate language representations for languages that the documented symbol doesn't already have a representation for. + // If the current symbol and its custom alternate representation share language representations, the custom language representation is ignored. + allVariants.merge( + alternateRepresentationReference.sourceLanguages.map { ($0, alternateRepresentationReference) } + ) { existing, _ in existing } + } + } + + return allVariants + .sorted(by: { variant1, variant2 in // Emit Swift first, then alphabetically. - switch (language1, language2) { + switch (variant1.key, variant2.key) { case (.swift, _): return true case (_, .swift): return false - default: return language1.id < language2.id - } - }) - .map { sourceLanguage in - RenderNode.Variant( - traits: [.interfaceLanguage(sourceLanguage.id)], - paths: [ - generator.presentationURLForReference(identifier).path - ] - ) + default: return variant1.key.id < variant2.key.id } + }) + .map { sourceLanguage, reference in + RenderNode.Variant( + traits: [.interfaceLanguage(sourceLanguage.id)], + paths: [ + generator.presentationURLForReference(reference).path + ] + ) + } } private mutating func convertFragments(_ fragments: [SymbolGraph.Symbol.DeclarationFragments.Fragment]) -> [DeclarationRenderSection.Token] { diff --git a/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift b/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift index b0b267ab0..ee1755b98 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift @@ -1495,4 +1495,84 @@ class RenderNodeTranslatorTests: XCTestCase { } } } + + func testAlternateRepresentationsRenderedAsVariants() throws { + let (bundle, context) = try loadBundle(catalog: Folder( + name: "unit-test.docc", + content: [ + TextFile(name: "Symbol.md", utf8Content: """ + # ``Symbol`` + @Metadata { + @AlternateRepresentation(``CounterpartSymbol``) + } + A symbol extension file defining an alternate representation. + """), + TextFile(name: "OtherSymbol.md", utf8Content: """ + # ``OtherSymbol`` + @Metadata { + @AlternateRepresentation(``MissingCounterpart``) + } + A symbol extension file defining an alternate representation which doesn't exist. + """), + TextFile(name: "MultipleSwiftVariantsSymbol.md", utf8Content: """ + # ``MultipleSwiftVariantsSymbol`` + @Metadata { + @AlternateRepresentation(``Symbol``) + } + A symbol extension file defining an alternate representation which is also in Swift. + """), + JSONFile( + name: "unit-test.swift.symbols.json", + content: makeSymbolGraph( + moduleName: "unit-test", + symbols: [ + makeSymbol(id: "symbol-id", kind: .class, pathComponents: ["Symbol"]), + makeSymbol(id: "other-symbol-id", kind: .class, pathComponents: ["OtherSymbol"]), + makeSymbol(id: "multiple-swift-variants-symbol-id", kind: .class, pathComponents: ["MultipleSwiftVariantsSymbol"]), + ] + ) + ), + JSONFile( + name: "unit-test.occ.symbols.json", + content: makeSymbolGraph( + moduleName: "unit-test", + symbols: [ + makeSymbol(id: "counterpart-symbol-id", language: .objectiveC, kind: .class, pathComponents: ["CounterpartSymbol"]), + ] + ) + ), + ] + )) + + func renderNodeArticleFromReferencePath( + referencePath: String + ) throws -> RenderNode { + let reference = ResolvedTopicReference(bundleID: bundle.id, path: referencePath, sourceLanguage: .swift) + let symbol = try XCTUnwrap(context.entity(with: reference).semantic as? Symbol) + var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: reference) + return try XCTUnwrap(translator.visitSymbol(symbol) as? RenderNode) + } + + // Assert that CounterpartSymbol's source languages have been added as source languages of Symbol + var renderNode = try renderNodeArticleFromReferencePath(referencePath: "/documentation/unit-test/Symbol") + XCTAssertEqual(renderNode.variants?.count, 2) + XCTAssertEqual(renderNode.variants, [ + .init(traits: [.interfaceLanguage("swift")], paths: ["/documentation/unit-test/symbol"]), + .init(traits: [.interfaceLanguage("occ")], paths: ["/documentation/unit-test/counterpartsymbol"]) + ]) + + // Assert that alternate representations which can't be resolved are ignored + renderNode = try renderNodeArticleFromReferencePath(referencePath: "/documentation/unit-test/OtherSymbol") + XCTAssertEqual(renderNode.variants?.count, 1) + XCTAssertEqual(renderNode.variants, [ + .init(traits: [.interfaceLanguage("swift")], paths: ["/documentation/unit-test/othersymbol"]), + ]) + + // Assert that duplicate alternate representations are not added as variants + renderNode = try renderNodeArticleFromReferencePath(referencePath: "/documentation/unit-test/MultipleSwiftVariantsSymbol") + XCTAssertEqual(renderNode.variants?.count, 1) + XCTAssertEqual(renderNode.variants, [ + .init(traits: [.interfaceLanguage("swift")], paths: ["/documentation/unit-test/multipleswiftvariantssymbol"]), + ]) + } }