From c2c02ca106e2c317546081912ae0fc5d0d8b287d Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:03:35 +0000 Subject: [PATCH 1/5] Add `@AlternateRepresentation` directive Adds a new child directive to `@Metadata` which can be used in a symbol extension file by specifying the link to another symbol: ```swift @Metadata { @AlternateRepresentation(``MyClass/property``) } ``` External links are also supported, as long as they're quoted: ```swift @Metadata { @AlternateRepresentation("doc://com.example/documentation/MyClass/property") } ``` The intent of this directive is to define an alternate language representation for the current symbol, such that both symbols are considered to be alternate representations of the same symbol. Ideally two symbols which are equivalent would have the same USR and be resolved as the same symbol by the compiler, but this is not always the case. For the cases in which it is not feasible to change the source code to have them as one symbol, the `@AlternateRepresentation` directive can be used to manually link them as variants of each other. Discussion: ---------- A mutable topic reference type was chosen as the type for parsing&storing the link so that it can be parsed as an unresolved link at the directive parsing stage, and then later be updated to a resolved link at a later stage when the documentation context is resolving all links. A parsing failure diagnostic is returned if the link is invalid in any way: ``` AlternateRepresentation expects an argument for an unnamed parameter that's convertible to 'TopicReference' --> SynonymSample.docc/Symbol.md:4:31-4:37 2 | 3 | @Metadata { 4 + @AlternateRepresentation("doc://") 5 | } 6 | ``` This commit adds the directive itself, but doesn't hook it up to other parts of SwiftDocC. Subsequent commits will add: - link resolution - rendering logic Alternatives considered: ----------------------- Considered other names such as `@Synonym`, `@Counterpart`, `@AlternativeDeclaration` and `@VariantOf`. In the end disqualified these as being confusing, and chose `@AlternateRepresentation` for being the one which strikes the best balance between readable and closeness to the technical term for this concept. --- .../Metadata/AlternateRepresentation.swift | 89 +++++++ .../Semantics/Metadata/Metadata.swift | 7 +- .../BlockDirectiveExtensions.swift | 1 + .../DocCDocumentation.docc/DocC.symbols.json | 242 ++++++++++++++++++ .../DirectiveIndexTests.swift | 1 + .../DirectiveMirrorTests.swift | 2 +- ...MetadataAlternateRepresentationTests.swift | 67 +++++ 7 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 Sources/SwiftDocC/Semantics/Metadata/AlternateRepresentation.swift create mode 100644 Tests/SwiftDocCTests/Semantics/MetadataAlternateRepresentationTests.swift diff --git a/Sources/SwiftDocC/Semantics/Metadata/AlternateRepresentation.swift b/Sources/SwiftDocC/Semantics/Metadata/AlternateRepresentation.swift new file mode 100644 index 0000000000..38e12e8a91 --- /dev/null +++ b/Sources/SwiftDocC/Semantics/Metadata/AlternateRepresentation.swift @@ -0,0 +1,89 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2024 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 Foundation +import Markdown + + +/// A directive that configures an alternate language representations of a symbol. +/// +/// An API that can be called from more than one source language has more than one language representation. +/// +/// Whenever possible, prefer to define alternative language representations for a symbol by using in-source annotations +/// such as the `@objc` and `@_objcImplementation` attributes in Swift, +/// or the `NS_SWIFT_NAME` macro in Objective C. +/// +/// If your source language doesn’t have a mechanism for specifying alternate representations or if your intended alternate representation isn't compatible with those attributes, +/// you can use the `@AlternateRepresentation` directive to specify another symbol that should be considered an alternate representation of the documented symbol. +/// +/// ```md +/// @Metadata { +/// @AlternateRepresentation(MyApp/MyClass/property) +/// } +/// ``` +/// If you prefer, you can wrap the symbol link in a set of double backticks (\`\`), or use any other supported syntax for linking to symbols. +/// For more information about linking to symbols, see . +/// +/// This provides a hint to the renderer as to the alternate language representations for the current symbol. +/// The renderer may use this hint to provide a link to these alternate symbols. +/// For example, Swift-DocC-Render shows a toggle between supported languages, where switching to a different language representation will redirect to the documentation for the configured alternate symbol. +/// +/// ### Special considerations +/// +/// Links containing a colon (`:`) must be wrapped in quotes: +/// ```md +/// @Metadata { +/// @AlternateRepresentation("doc://com.example/documentation/MyClass/property") +/// @AlternateRepresentation("MyClass/myFunc(_:_:)") +/// } +/// ``` +/// +/// The `@AlternateRepresentation` directive only specifies an alternate language representation in one direction. +/// To define a two-way relationship, add an `@AlternateRepresentation` directive, linking to this symbol, to the other symbol as well. +/// +/// You can only configure custom alternate language representations for languages that the documented symbol doesn't already have a language representation for, +/// either from in-source annotations or from a previous `@AlternateRepresentation` directive. +public final class AlternateRepresentation: Semantic, AutomaticDirectiveConvertible { + public static let introducedVersion = "6.1" + + // Directive parameter definition + + /// A link to another symbol that should be considered an alternate language representation of the current symbol. + /// + /// If you prefer, you can wrap the symbol link in a set of double backticks (\`\`). + @DirectiveArgumentWrapped( + name: .unnamed, + parseArgument: { _, argumentValue in + // Allow authoring of links with leading and trailing "``"s + var argumentValue = argumentValue + if argumentValue.hasPrefix("``"), argumentValue.hasSuffix("``") { + argumentValue = String(argumentValue.dropFirst(2).dropLast(2)) + } + guard let url = ValidatedURL(parsingAuthoredLink: argumentValue), !url.components.path.isEmpty else { + return nil + } + return .unresolved(UnresolvedTopicReference(topicURL: url)) + } + ) + public internal(set) var reference: TopicReference + + static var keyPaths: [String : AnyKeyPath] = [ + "reference" : \AlternateRepresentation._reference + ] + + // Boiler-plate required by conformance to AutomaticDirectiveConvertible + + public var originalMarkup: Markdown.BlockDirective + + @available(*, deprecated, message: "Do not call directly. Required for 'AutomaticDirectiveConvertible") + init(originalMarkup: Markdown.BlockDirective) { + self.originalMarkup = originalMarkup + } +} diff --git a/Sources/SwiftDocC/Semantics/Metadata/Metadata.swift b/Sources/SwiftDocC/Semantics/Metadata/Metadata.swift index da3d129cdc..cc79c5423b 100644 --- a/Sources/SwiftDocC/Semantics/Metadata/Metadata.swift +++ b/Sources/SwiftDocC/Semantics/Metadata/Metadata.swift @@ -19,6 +19,7 @@ import Markdown /// /// ### Child Directives /// +/// - ``AlternateRepresentation`` /// - ``DocumentationExtension`` /// - ``TechnologyRoot`` /// - ``DisplayName`` @@ -77,6 +78,9 @@ public final class Metadata: Semantic, AutomaticDirectiveConvertible { @ChildDirective var redirects: [Redirect]? = nil + + @ChildDirective(requirements: .zeroOrMore) + var alternateRepresentations: [AlternateRepresentation] static var keyPaths: [String : AnyKeyPath] = [ "documentationOptions" : \Metadata._documentationOptions, @@ -91,6 +95,7 @@ public final class Metadata: Semantic, AutomaticDirectiveConvertible { "_pageColor" : \Metadata.__pageColor, "titleHeading" : \Metadata._titleHeading, "redirects" : \Metadata._redirects, + "alternateRepresentations" : \Metadata._alternateRepresentations, ] @available(*, deprecated, message: "Do not call directly. Required for 'AutomaticDirectiveConvertible'.") @@ -100,7 +105,7 @@ public final class Metadata: Semantic, AutomaticDirectiveConvertible { func validate(source: URL?, for bundle: DocumentationBundle, in context: DocumentationContext, problems: inout [Problem]) -> Bool { // Check that something is configured in the metadata block - if documentationOptions == nil && technologyRoot == nil && displayName == nil && pageImages.isEmpty && customMetadata.isEmpty && callToAction == nil && availability.isEmpty && pageKind == nil && pageColor == nil && titleHeading == nil && redirects == nil { + if documentationOptions == nil && technologyRoot == nil && displayName == nil && pageImages.isEmpty && customMetadata.isEmpty && callToAction == nil && availability.isEmpty && pageKind == nil && pageColor == nil && titleHeading == nil && redirects == nil && alternateRepresentations.isEmpty { let diagnostic = Diagnostic( source: source, severity: .information, diff --git a/Sources/SwiftDocC/Utility/MarkupExtensions/BlockDirectiveExtensions.swift b/Sources/SwiftDocC/Utility/MarkupExtensions/BlockDirectiveExtensions.swift index c1dbee21a4..bb7de317d7 100644 --- a/Sources/SwiftDocC/Utility/MarkupExtensions/BlockDirectiveExtensions.swift +++ b/Sources/SwiftDocC/Utility/MarkupExtensions/BlockDirectiveExtensions.swift @@ -41,6 +41,7 @@ extension BlockDirective { Metadata.directiveName, Metadata.Availability.directiveName, Metadata.PageKind.directiveName, + AlternateRepresentation.directiveName, MultipleChoice.directiveName, Options.directiveName, PageColor.directiveName, diff --git a/Sources/docc/DocCDocumentation.docc/DocC.symbols.json b/Sources/docc/DocCDocumentation.docc/DocC.symbols.json index 75b7a14bff..7ae310255e 100644 --- a/Sources/docc/DocCDocumentation.docc/DocC.symbols.json +++ b/Sources/docc/DocCDocumentation.docc/DocC.symbols.json @@ -26,6 +26,245 @@ ], "symbols" : [ + { + "accessLevel" : "public", + "availability" : [ + { + "domain" : "Swift-DocC", + "introduced" : { + "major" : 6, + "minor" : 1, + "patch" : 0 + } + } + ], + "declarationFragments" : [ + { + "kind" : "typeIdentifier", + "spelling" : "@" + }, + { + "kind" : "typeIdentifier", + "spelling" : "AlternateRepresentation" + }, + { + "kind" : "text", + "spelling" : "(" + }, + { + "kind" : "text", + "spelling" : "_ " + }, + { + "kind" : "identifier", + "spelling" : "reference" + }, + { + "kind" : "text", + "spelling" : ": " + }, + { + "kind" : "typeIdentifier", + "spelling" : "TopicReference" + }, + { + "kind" : "text", + "spelling" : ")" + } + ], + "docComment" : { + "lines" : [ + { + "text" : "A directive that configures an alternate language representations of a symbol." + }, + { + "text" : "" + }, + { + "text" : "An API that can be called from more than one source language has more than one language representation." + }, + { + "text" : "" + }, + { + "text" : "Whenever possible, prefer to define alternative language representations for a symbol by using in-source annotations" + }, + { + "text" : "such as the `@objc` and `@_objcImplementation` attributes in Swift," + }, + { + "text" : "or the `NS_SWIFT_NAME` macro in Objective C." + }, + { + "text" : "" + }, + { + "text" : "If your source language doesn’t have a mechanism for specifying alternate representations or if your intended alternate representation isn't compatible with those attributes," + }, + { + "text" : "you can use the `@AlternateRepresentation` directive to specify another symbol that should be considered an alternate representation of the documented symbol." + }, + { + "text" : "" + }, + { + "text" : "```md" + }, + { + "text" : "@Metadata {" + }, + { + "text" : " @AlternateRepresentation(MyApp\/MyClass\/property)" + }, + { + "text" : "}" + }, + { + "text" : "```" + }, + { + "text" : "If you prefer, you can wrap the symbol link in a set of double backticks (\\`\\`), or use any other supported syntax for linking to symbols." + }, + { + "text" : "For more information about linking to symbols, see ." + }, + { + "text" : "" + }, + { + "text" : "This provides a hint to the renderer as to the alternate language representations for the current symbol." + }, + { + "text" : "The renderer may use this hint to provide a link to these alternate symbols." + }, + { + "text" : "For example, Swift-DocC-Render shows a toggle between supported languages, where switching to a different language representation will redirect to the documentation for the configured alternate symbol." + }, + { + "text" : "" + }, + { + "text" : "### Special considerations" + }, + { + "text" : "" + }, + { + "text" : "Links containing a colon (`:`) must be wrapped in quotes:" + }, + { + "text" : "```md" + }, + { + "text" : "@Metadata {" + }, + { + "text" : " @AlternateRepresentation(\"doc:\/\/com.example\/documentation\/MyClass\/property\")" + }, + { + "text" : " @AlternateRepresentation(\"MyClass\/myFunc(_:_:)\")" + }, + { + "text" : "}" + }, + { + "text" : "```" + }, + { + "text" : "" + }, + { + "text" : "The `@AlternateRepresentation` directive only specifies an alternate language representation in one direction." + }, + { + "text" : "To define a two-way relationship, add an `@AlternateRepresentation` directive, linking to this symbol, to the other symbol as well." + }, + { + "text" : "" + }, + { + "text" : "You can only configure custom alternate language representations for languages that the documented symbol doesn't already have a language representation for," + }, + { + "text" : "either from in-source annotations or from a previous `@AlternateRepresentation` directive." + }, + { + "text" : "- Parameters:" + }, + { + "text" : " - reference: A link to another symbol that should be considered an alternate language representation of the current symbol." + }, + { + "text" : " **(required)**" + }, + { + "text" : " " + }, + { + "text" : " If you prefer, you can wrap the symbol link in a set of double backticks (\\`\\`)." + } + ] + }, + "identifier" : { + "interfaceLanguage" : "swift", + "precise" : "__docc_universal_symbol_reference_$AlternateRepresentation" + }, + "kind" : { + "displayName" : "Directive", + "identifier" : "class" + }, + "names" : { + "navigator" : [ + { + "kind" : "attribute", + "spelling" : "@" + }, + { + "kind" : "identifier", + "preciseIdentifier" : "__docc_universal_symbol_reference_$AlternateRepresentation", + "spelling" : "AlternateRepresentation" + } + ], + "subHeading" : [ + { + "kind" : "identifier", + "spelling" : "@" + }, + { + "kind" : "identifier", + "spelling" : "AlternateRepresentation" + }, + { + "kind" : "text", + "spelling" : "(" + }, + { + "kind" : "text", + "spelling" : "_ " + }, + { + "kind" : "identifier", + "spelling" : "reference" + }, + { + "kind" : "text", + "spelling" : ": " + }, + { + "kind" : "typeIdentifier", + "spelling" : "TopicReference" + }, + { + "kind" : "text", + "spelling" : ")" + } + ], + "title" : "AlternateRepresentation" + }, + "pathComponents" : [ + "AlternateRepresentation" + ] + }, { "accessLevel" : "public", "availability" : [ @@ -3347,6 +3586,9 @@ { "text" : "" }, + { + "text" : "- ``AlternateRepresentation``" + }, { "text" : "- ``DocumentationExtension``" }, diff --git a/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift b/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift index 0228dc181d..9afdba9963 100644 --- a/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift +++ b/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift @@ -17,6 +17,7 @@ class DirectiveIndexTests: XCTestCase { XCTAssertEqual( DirectiveIndex.shared.indexedDirectives.keys.sorted(), [ + "AlternateRepresentation", "Assessments", "AutomaticArticleSubheading", "AutomaticSeeAlso", diff --git a/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveMirrorTests.swift b/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveMirrorTests.swift index 9d84424c9f..88662c38cc 100644 --- a/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveMirrorTests.swift +++ b/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveMirrorTests.swift @@ -52,7 +52,7 @@ class DirectiveMirrorTests: XCTestCase { XCTAssertFalse(reflectedDirective.allowsMarkup) XCTAssert(reflectedDirective.arguments.isEmpty) - XCTAssertEqual(reflectedDirective.childDirectives.count, 12) + XCTAssertEqual(reflectedDirective.childDirectives.count, 13) XCTAssertEqual( reflectedDirective.childDirectives["DocumentationExtension"]?.propertyLabel, diff --git a/Tests/SwiftDocCTests/Semantics/MetadataAlternateRepresentationTests.swift b/Tests/SwiftDocCTests/Semantics/MetadataAlternateRepresentationTests.swift new file mode 100644 index 0000000000..9fc696e6eb --- /dev/null +++ b/Tests/SwiftDocCTests/Semantics/MetadataAlternateRepresentationTests.swift @@ -0,0 +1,67 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2024 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 Foundation +import XCTest +import Markdown + +@testable import SwiftDocC + +class MetadataAlternateRepresentationTests: XCTestCase { + func testValidLocalLink() throws { + for link in ["``MyClass/property``", "MyClass/property"] { + let (problems, metadata) = try parseDirective(Metadata.self) { + """ + @Metadata { + @AlternateRepresentation(\(link)) + } + """ + } + + XCTAssertTrue(problems.isEmpty, "Unexpected problems: \(problems.joined(separator: "\n"))") + XCTAssertEqual(metadata?.alternateRepresentations.count, 1) + + let alternateRepresentation = try XCTUnwrap(metadata?.alternateRepresentations.first) + XCTAssertEqual(alternateRepresentation.reference.url, URL(string: "MyClass/property")) + } + } + + func testValidExternalLinkReference() throws { + let (problems, metadata) = try parseDirective(Metadata.self) { + """ + @Metadata { + @AlternateRepresentation("doc://com.example/documentation/MyClass/property") + } + """ + } + + XCTAssertTrue(problems.isEmpty, "Unexpected problems: \(problems.joined(separator: "\n"))") + XCTAssertEqual(metadata?.alternateRepresentations.count, 1) + + let alternateRepresentation = try XCTUnwrap(metadata?.alternateRepresentations.first) + XCTAssertEqual(alternateRepresentation.reference.url, URL(string: "doc://com.example/documentation/MyClass/property")) + } + + func testInvalidTopicReference() throws { + let (problems, _) = try parseDirective(Metadata.self) { + """ + @Metadata { + @AlternateRepresentation("doc://") + } + """ + } + + XCTAssertEqual(problems.count, 2, "Unexpected number of problems: \(problems.joined(separator: "\n"))") + XCTAssertEqual(problems, [ + "1: note – org.swift.docc.Metadata.NoConfiguration", + "2: warning – org.swift.docc.HasArgument.unlabeled.ConversionFailed" + ]) + } +} From c46e029fd4c8a7c7af57acacb512e419db041e4e Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:11:54 +0000 Subject: [PATCH 2/5] Resolve topic references in `@AlternateRepresentation` Adds logic in `DocumentationContext` which will resolve the references inside the alternate representation directive. The same logic is used as for resolving all other links. This is done outside the usual ReferenceResolver visit of the semantic object, because `Symbol` objects don't have access to the node metadata, where the unresolved link resides. If the link cannot be resolved, the usual diagnostic is emitted: ``` warning: 'MissingSymbol' doesn't exist at '/Synonyms' --> SynonymSample.docc/SymbolExtension2.md:4:19-4:32 2 | 3 | @Metadata { 4 + @AlternateRepresentation(``Synonyms/MissingSymbol``) 5 | } ``` --- .../Infrastructure/DocumentationContext.swift | 13 ++++ .../DocumentationContextTests.swift | 78 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index c1495ab55a..5c6a985b77 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -618,6 +618,19 @@ public class DocumentationContext { resolver.visit(documentationNode.semantic) } + // Also resolve the node's alternate representations. This isn't part of the node's 'semantic' value (resolved above). + if let alternateRepresentations = documentationNode.metadata?.alternateRepresentations { + for alternateRepresentation in alternateRepresentations { + let resolutionResult = resolver.resolve( + alternateRepresentation.reference, + in: bundle.rootReference, + range: alternateRepresentation.originalMarkup.range, + severity: .warning + ) + alternateRepresentation.reference = .resolved(resolutionResult) + } + } + let problems: [Problem] if documentationNode.semantic is Article { // Diagnostics for articles have correct source ranges and don't need to be modified. diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift index 609bcab8ed..8b54c65015 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift @@ -5356,6 +5356,84 @@ let expected = """ XCTAssertEqual(externalRenderReference.title, externalModuleName) XCTAssertEqual(externalRenderReference.abstract, [.text("Some description of this module.")]) } + + func testResolvesAlternateDeclarations() throws { + let (bundle, context) = try loadBundle(catalog: Folder( + name: "unit-test.docc", + content: [ + TextFile(name: "Symbol.md", utf8Content: """ + # ``Symbol`` + @Metadata { + @AlternateRepresentation(``CounterpartSymbol``) + @AlternateRepresentation(OtherCounterpartSymbol) + @AlternateRepresentation(``MissingSymbol``) + } + A symbol extension file defining an alternate representation. + """), + JSONFile( + name: "unit-test.swift.symbols.json", + content: makeSymbolGraph( + moduleName: "unit-test", + symbols: [ + makeSymbol(id: "symbol-id", kind: .class, pathComponents: ["Symbol"]), + ] + ) + ), + JSONFile( + name: "unit-test.occ.symbols.json", + content: makeSymbolGraph( + moduleName: "unit-test", + symbols: [ + makeSymbol(id: "counterpart-symbol-id", language: .objectiveC, kind: .class, pathComponents: ["CounterpartSymbol"]), + ] + ) + ), + JSONFile( + name: "unit-test.js.symbols.json", + content: makeSymbolGraph( + moduleName: "unit-test", + symbols: [ + makeSymbol(id: "other-counterpart-symbol-id", language: .javaScript, kind: .class, pathComponents: ["OtherCounterpartSymbol"]), + ] + ) + ), + ] + )) + + let reference = ResolvedTopicReference(bundleID: bundle.id, path: "/documentation/unit-test/Symbol", sourceLanguage: .swift) + + let entity = try context.entity(with: reference) + XCTAssertEqual(entity.metadata?.alternateRepresentations.count, 3) + + // First alternate representation should have been resolved successfully + var alternateRepresentation = try XCTUnwrap(entity.metadata?.alternateRepresentations.first) + XCTAssertEqual( + alternateRepresentation.reference, + .resolved(.success(.init(bundleID: bundle.id, path: "/documentation/unit-test/CounterpartSymbol", sourceLanguage: .objectiveC))) + ) + + // Second alternate representation without "``" should also have been resolved successfully + alternateRepresentation = try XCTUnwrap(entity.metadata?.alternateRepresentations.dropFirst().first) + XCTAssertEqual( + alternateRepresentation.reference, + .resolved(.success(.init(bundleID: bundle.id, path: "/documentation/unit-test/OtherCounterpartSymbol", sourceLanguage: .objectiveC))) + ) + + // Third alternate representation shouldn't have been resolved at all + alternateRepresentation = try XCTUnwrap(entity.metadata?.alternateRepresentations.dropFirst().last) + guard case .resolved(.failure(let unresolvedPath, _)) = alternateRepresentation.reference else { + XCTFail("Expected alternate representation to be unresolved, but was resolved as \(alternateRepresentation.reference)") + return + } + XCTAssertEqual(unresolvedPath, .init(topicURL: .init(parsingAuthoredLink: "MissingSymbol")!)) + + // And an error should have been reported + XCTAssertEqual(context.problems.count, 1) + + let problem = try XCTUnwrap(context.problems.first) + XCTAssertEqual(problem.diagnostic.severity, .warning) + XCTAssertEqual(problem.diagnostic.summary, "Can't resolve 'MissingSymbol'") + } } func assertEqualDumps(_ lhs: String, _ rhs: String, file: StaticString = #file, line: UInt = #line) { From 213ee0435c3cb8b91919ec818fa5fcb9bbfd178c Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:12:20 +0000 Subject: [PATCH 3/5] Diagnose duplicate representations of `@AlternateRepresentation` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If an `@AlternateRepresentation` clashes with already available source languages, this will now be reported as diagnostics. These diagnostics are performed in the final stage of registering a bundle, during the global analysis of the topic graph, where all nodes are available and all links will have been resolved. This is so that we have all the information we need for detecting duplicates. The following cases are detected: - if the symbol the alternate representation is being defined for (the "original" symbol) was already available in one of the languages the counterpart symbol is available in - if the alternate representations have duplicate source languages in common, i.e. if counterpart1 is available in Objective-C and counterpart2 is **also** available in Objective-C. Suggestions will be provided depending on context: - which languages are duplicate - all the languages the symbol is already available in will be available as part of the diagnostic explanation - if the `@AlternateRepresentation` directive is a duplicate, a suggestion will be made to remove it, with a suitable replacement - if the `@AlternateRepresentation` directive is a duplicate, a note pointing to the original directive will be added Example diagnostics: ``` warning: An alternate representation for Swift already exists This node is already available in Swift and Objective-C. SynonymSample.docc/SymbolExtension2.md:4:5: An alternate representation for Swift has already been defined by an @AlternateRepresentation directive. --> SynonymSample.docc/SymbolExtension2.md:5:5-5:57 3 | @Metadata { 4 | @AlternateRepresentation(``Synonyms/Synonym-5zxmc``) 5 + @AlternateRepresentation(``Synonyms/Synonym-5zxmc``) | ╰─suggestion: Remove this alternate representation 6 | } 7 | ``` ``` warning: This node already has a representation in Swift This node is already available in Swift. --> SynonymSample.docc/SynonymExtension.md:5:5-5:56 3 | @Metadata { 4 | @AlternateRepresentation(``Synonyms/Synonym-1wqxt``) 5 + @AlternateRepresentation(``Synonyms/OtherSynonym``) | ╰─suggestion: Replace the counterpart link with a node which isn't available in Swift 6 | } 7 | ``` --- .../Infrastructure/DocumentationContext.swift | 84 +++++++++++++++++++ .../DocumentationContextTests.swift | 73 +++++++++++++++- 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index 5c6a985b77..9d0e92c32d 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -2835,6 +2835,9 @@ public class DocumentationContext { } } + // Run analysis to determine whether manually configured alternate representations are valid. + analyzeAlternateRepresentations() + // Run global ``TopicGraph`` global analysis. analyzeTopicGraph() } @@ -3199,6 +3202,87 @@ extension DocumentationContext { } diagnosticEngine.emit(problems) } + + func analyzeAlternateRepresentations() { + var problems = [Problem]() + + func listSourceLanguages(_ sourceLanguages: Set) -> String { + sourceLanguages.sorted(by: { language1, language2 in + // Emit Swift first, then alphabetically. + switch (language1, language2) { + case (.swift, _): return true + case (_, .swift): return false + default: return language1.id < language2.id + } + }).map(\.name).list(finalConjunction: .and) + } + func removeAlternateRepresentationSolution(_ alternateRepresentation: AlternateRepresentation) -> [Solution] { + [Solution( + summary: "Remove this alternate representation", + replacements: alternateRepresentation.originalMarkup.range.map { [Replacement(range: $0, replacement: "")] } ?? [])] + } + + for reference in knownPages { + guard let entity = try? self.entity(with: reference), let alternateRepresentations = entity.metadata?.alternateRepresentations else { continue } + + var sourceLanguageToReference: [SourceLanguage: AlternateRepresentation] = [:] + for alternateRepresentation in entity.metadata?.alternateRepresentations ?? [] { + guard case .resolved(.success(let alternateRepresentationReference)) = alternateRepresentation.reference, + let alternateRepresentationEntity = try? self.entity(with: alternateRepresentationReference) else { + continue + } + + // Check if the documented symbol already has alternate representations from in-source annotations. + let duplicateSourceLanguages = alternateRepresentationEntity.availableSourceLanguages.intersection(entity.availableSourceLanguages) + if !duplicateSourceLanguages.isEmpty { + problems.append(Problem( + diagnostic: Diagnostic( + source: alternateRepresentation.originalMarkup.range?.source, + severity: .warning, + range: alternateRepresentation.originalMarkup.range, + identifier: "org.swift.docc.AlternateRepresentation.DuplicateLanguageDefinition", + summary: "\(entity.name.plainText.singleQuoted) already has a representation in \(listSourceLanguages(duplicateSourceLanguages))", + explanation: "Symbols can only specify custom alternate language representations for languages that the documented symbol doesn't already have a representation for." + ), + possibleSolutions: [Solution(summary: "Replace this alternate language representation with a symbol which isn't available in \(listSourceLanguages(entity.availableSourceLanguages))", replacements: [])] + )) + } + + let duplicateAlternateLanguages = Set(sourceLanguageToReference.keys).intersection(alternateRepresentationEntity.availableSourceLanguages) + if !duplicateAlternateLanguages.isEmpty { + let replacements = alternateRepresentation.originalMarkup.range.flatMap { [Replacement(range: $0, replacement: "")] } ?? [] + let notes: [DiagnosticNote] = duplicateAlternateLanguages.compactMap { duplicateAlternateLanguage in + guard let alreadyExistingRepresentation = sourceLanguageToReference[duplicateAlternateLanguage], + let range = alreadyExistingRepresentation.originalMarkup.range, + let source = range.source else { + return nil + } + + return DiagnosticNote(source: source, range: range, message: "This directive already specifies an alternate \(duplicateAlternateLanguage.name) representation.") + } + problems.append(Problem( + diagnostic: Diagnostic( + source: alternateRepresentation.originalMarkup.range?.source, + severity: .warning, + range: alternateRepresentation.originalMarkup.range, + identifier: "org.swift.docc.AlternateRepresentation.DuplicateLanguageDefinition", + summary: "A custom alternate language representation for \(listSourceLanguages(duplicateAlternateLanguages)) has already been specified", + explanation: "Only one custom alternate language representation can be specified per language.", + notes: notes + ), + possibleSolutions: [Solution(summary: "Remove this alternate representation", replacements: replacements)] + )) + } + + // Update mapping from source language to alternate declaration, for diagnostic purposes + for alreadySeenLanguage in alternateRepresentationEntity.availableSourceLanguages { + sourceLanguageToReference[alreadySeenLanguage] = alternateRepresentation + } + } + } + + diagnosticEngine.emit(problems) + } } extension GraphCollector.GraphKind { diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift index 8b54c65015..a454ab3e1a 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift @@ -5433,7 +5433,78 @@ let expected = """ let problem = try XCTUnwrap(context.problems.first) XCTAssertEqual(problem.diagnostic.severity, .warning) XCTAssertEqual(problem.diagnostic.summary, "Can't resolve 'MissingSymbol'") - } + } + + func testDiagnosesAlternateDeclarations() throws { + let (_, context) = try loadBundle(catalog: Folder( + name: "unit-test.docc", + content: [ + TextFile(name: "Symbol.md", utf8Content: """ + # ``Symbol`` + @Metadata { + @AlternateRepresentation(``CounterpartSymbol``) + @AlternateRepresentation(``OtherCounterpartSymbol``) + } + A symbol extension file defining an alternate representation which overlaps source languages with another one. + """), + TextFile(name: "SwiftSymbol.md", utf8Content: """ + # ``SwiftSymbol`` + @Metadata { + @AlternateRepresentation(``Symbol``) + } + A symbol extension file defining an alternate representation which overlaps source languages with the current node. + """), + 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: ["SwiftSymbol"]), + ] + ) + ), + JSONFile( + name: "unit-test.occ.symbols.json", + content: makeSymbolGraph( + moduleName: "unit-test", + symbols: [ + makeSymbol(id: "counterpart-symbol-id", language: .objectiveC, kind: .class, pathComponents: ["CounterpartSymbol"]), + makeSymbol(id: "other-counterpart-symbol-id", language: .objectiveC, kind: .class, pathComponents: ["OtherCounterpartSymbol"]), + ] + ) + ), + ] + )) + + let alternateRepresentationProblems = context.problems.sorted(by: \.diagnostic.summary) + XCTAssertEqual(alternateRepresentationProblems.count, 2) + + // Verify a problem is reported for trying to define an alternate representation for a language the symbol already supports + var problem = try XCTUnwrap(alternateRepresentationProblems.first) + XCTAssertEqual(problem.diagnostic.severity, .warning) + XCTAssertEqual(problem.diagnostic.summary, "'SwiftSymbol' already has a representation in Swift") + XCTAssertEqual(problem.diagnostic.explanation, "Symbols can only specify custom alternate language representations for languages that the documented symbol doesn't already have a representation for.") + XCTAssertEqual(problem.possibleSolutions.count, 1) + + // Verify solutions provide context, but no replacements + var solution = try XCTUnwrap(problem.possibleSolutions.first) + XCTAssertEqual(solution.summary, "Replace this alternate language representation with a symbol which isn't available in Swift") + XCTAssertEqual(solution.replacements.count, 0) + + // Verify a problem is reported for having alternate representations with duplicate source languages + problem = try XCTUnwrap(alternateRepresentationProblems[1]) + XCTAssertEqual(problem.diagnostic.severity, .warning) + XCTAssertEqual(problem.diagnostic.summary, "A custom alternate language representation for Objective-C has already been specified") + XCTAssertEqual(problem.diagnostic.explanation, "Only one custom alternate language representation can be specified per language.") + XCTAssertEqual(problem.possibleSolutions.count, 1) + + // Verify solutions provide context and suggest to remove the duplicate directive + solution = try XCTUnwrap(problem.possibleSolutions.first) + XCTAssertEqual(solution.summary, "Remove this alternate representation") + XCTAssertEqual(solution.replacements.count, 1) + XCTAssertEqual(solution.replacements.first?.replacement, "") + } } func assertEqualDumps(_ lhs: String, _ rhs: String, file: StaticString = #file, line: UInt = #line) { From 711469a9367b4ca7c38ba41949cc440104e9a975 Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Thu, 12 Dec 2024 16:27:12 +0000 Subject: [PATCH 4/5] Emit diagnostics for non-symbol pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `@AlternateRepresentation` directive is not expected for non-symbol pages, and we now emit diagnostics for this case. For example, if an `@AlternateDeclaration` directive is added to an article, the resulting diagnostic will be: ``` warning: Custom alternate representations are not supported for page kind 'Article' Alternate representations are only supported for symbols. --> ./SynonymSample.docc/Article.md:4:5-4:57 2 | 3 | @Metadata { 4 + @AlternateRepresentation(``Synonyms/Synonym-5zxmc``) | ╰─suggestion: Remove this alternate representation 5 | } ``` And if a custom alternate declaration to an article is specified, the resulting dia gnostic will be: ``` warning: Page kind 'Article' is not allowed as a custom alternate language representation Symbols can only specify other symbols as custom language representations. --> ./SynonymSample.docc/Synonym-1wqxt.md:5:5-5:44 3 | @Metadata { 4 | @AlternateRepresentation(``Synonyms/Synonym-5zxmc``) 5 + @AlternateRepresentation("doc:Article") | ╰─suggestion: Remove this alternate representation 6 | } ``` --- .../Infrastructure/DocumentationContext.swift | 37 ++++++++++- .../DocumentationContextTests.swift | 63 ++++++++++++++++++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index 9d0e92c32d..20cce55ce5 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -3226,12 +3226,44 @@ extension DocumentationContext { guard let entity = try? self.entity(with: reference), let alternateRepresentations = entity.metadata?.alternateRepresentations else { continue } var sourceLanguageToReference: [SourceLanguage: AlternateRepresentation] = [:] - for alternateRepresentation in entity.metadata?.alternateRepresentations ?? [] { + for alternateRepresentation in alternateRepresentations { + // Check if the entity is not a symbol, as only symbols are allowed to specify custom alternate representations + guard entity.symbol != nil else { + problems.append(Problem( + diagnostic: Diagnostic( + source: alternateRepresentation.originalMarkup.range?.source, + severity: .warning, + range: alternateRepresentation.originalMarkup.range, + identifier: "org.swift.docc.AlternateRepresentation.UnsupportedPageKind", + summary: "Custom alternate representations are not supported for page kind \(entity.kind.name.singleQuoted)", + explanation: "Alternate representations are only supported for symbols." + ), + possibleSolutions: removeAlternateRepresentationSolution(alternateRepresentation) + )) + continue + } + guard case .resolved(.success(let alternateRepresentationReference)) = alternateRepresentation.reference, let alternateRepresentationEntity = try? self.entity(with: alternateRepresentationReference) else { continue } + // Check if the resolved entity is not a symbol, as only symbols are allowed as custom alternate representations + guard alternateRepresentationEntity.symbol != nil else { + problems.append(Problem( + diagnostic: Diagnostic( + source: alternateRepresentation.originalMarkup.range?.source, + severity: .warning, + range: alternateRepresentation.originalMarkup.range, + identifier: "org.swift.docc.AlternateRepresentation.UnsupportedPageKind", + summary: "Page kind \(alternateRepresentationEntity.kind.name.singleQuoted) is not allowed as a custom alternate language representation", + explanation: "Symbols can only specify other symbols as custom language representations." + ), + possibleSolutions: removeAlternateRepresentationSolution(alternateRepresentation) + )) + continue + } + // Check if the documented symbol already has alternate representations from in-source annotations. let duplicateSourceLanguages = alternateRepresentationEntity.availableSourceLanguages.intersection(entity.availableSourceLanguages) if !duplicateSourceLanguages.isEmpty { @@ -3250,7 +3282,6 @@ extension DocumentationContext { let duplicateAlternateLanguages = Set(sourceLanguageToReference.keys).intersection(alternateRepresentationEntity.availableSourceLanguages) if !duplicateAlternateLanguages.isEmpty { - let replacements = alternateRepresentation.originalMarkup.range.flatMap { [Replacement(range: $0, replacement: "")] } ?? [] let notes: [DiagnosticNote] = duplicateAlternateLanguages.compactMap { duplicateAlternateLanguage in guard let alreadyExistingRepresentation = sourceLanguageToReference[duplicateAlternateLanguage], let range = alreadyExistingRepresentation.originalMarkup.range, @@ -3270,7 +3301,7 @@ extension DocumentationContext { explanation: "Only one custom alternate language representation can be specified per language.", notes: notes ), - possibleSolutions: [Solution(summary: "Remove this alternate representation", replacements: replacements)] + possibleSolutions: removeAlternateRepresentationSolution(alternateRepresentation) )) } diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift index a454ab3e1a..acc097ab57 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift @@ -5435,7 +5435,7 @@ let expected = """ XCTAssertEqual(problem.diagnostic.summary, "Can't resolve 'MissingSymbol'") } - func testDiagnosesAlternateDeclarations() throws { + func testDiagnosesSymbolAlternateDeclarations() throws { let (_, context) = try loadBundle(catalog: Folder( name: "unit-test.docc", content: [ @@ -5505,6 +5505,67 @@ let expected = """ XCTAssertEqual(solution.replacements.count, 1) XCTAssertEqual(solution.replacements.first?.replacement, "") } + + func testDiagnosesArticleAlternateDeclarations() throws { + let (_, context) = try loadBundle(catalog: Folder( + name: "unit-test.docc", + content: [ + TextFile(name: "Symbol.md", utf8Content: """ + # ``Symbol`` + @Metadata { + @AlternateRepresentation("doc:Article") + } + A symbol extension file specifying an alternate representation which is an article. + """), + TextFile(name: "Article.md", utf8Content: """ + # Article + @Metadata { + @AlternateRepresentation(``Symbol``) + } + An article specifying a custom alternate representation. + """), + JSONFile( + name: "unit-test.occ.symbols.json", + content: makeSymbolGraph( + moduleName: "unit-test", + symbols: [ + makeSymbol(id: "symbol-id", kind: .class, pathComponents: ["Symbol"]), + ] + ) + ) + ] + )) + + let alternateRepresentationProblems = context.problems.sorted(by: \.diagnostic.summary) + XCTAssertEqual(alternateRepresentationProblems.count, 2) + + // Verify a problem is reported for trying to define an alternate representation for a language the symbol already supports + var problem = try XCTUnwrap(alternateRepresentationProblems.first) + XCTAssertEqual(problem.diagnostic.severity, .warning) + XCTAssertEqual(problem.diagnostic.summary, "Custom alternate representations are not supported for page kind 'Article'") + XCTAssertEqual(problem.diagnostic.explanation, "Alternate representations are only supported for symbols.") + XCTAssertEqual(problem.possibleSolutions.count, 1) + + // Verify solutions provide context and suggest to remove the invalid directive + var solution = try XCTUnwrap(problem.possibleSolutions.first) + XCTAssertEqual(solution.summary, "Remove this alternate representation") + XCTAssertEqual(solution.replacements.count, 1) + XCTAssertEqual(solution.replacements.first?.replacement, "") + + // Verify a problem is reported for having alternate representations with duplicate source languages + problem = try XCTUnwrap(alternateRepresentationProblems[1]) + XCTAssertEqual(problem.diagnostic.severity, .warning) + XCTAssertEqual(problem.diagnostic.summary, "Page kind 'Article' is not allowed as a custom alternate language representation") + XCTAssertEqual(problem.diagnostic.explanation, "Symbols can only specify other symbols as custom language representations.") + XCTAssertEqual(problem.possibleSolutions.count, 1) + + // Verify solutions provide context and suggest to remove the invalid directive + solution = try XCTUnwrap(problem.possibleSolutions.first) + XCTAssertEqual(solution.summary, "Remove this alternate representation") + XCTAssertEqual(solution.replacements.count, 1) + XCTAssertEqual(solution.replacements.first?.replacement, "") + } + } func assertEqualDumps(_ lhs: String, _ rhs: String, file: StaticString = #file, line: UInt = #line) { From 74f3f2cbe33ef0094202edeb8e28791755564891 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 5/5] 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 adad3d7d5d..3dd473b1a9 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 b0b267ab05..ee1755b981 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"]), + ]) + } }