From 2ff4e82282b7e67def6b7cdc1a9ef7d1ea6fc6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 5 Dec 2025 17:22:01 +0100 Subject: [PATCH] Add a helper function for rendering symbol declarations as HTML rdar://163326857 --- Sources/DocCHTML/CMakeLists.txt | 1 + .../MarkdownRenderer+Declaration.swift | 75 +++++++++ .../MarkdownRenderer+PageElementsTests.swift | 153 ++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 Sources/DocCHTML/MarkdownRenderer+Declaration.swift diff --git a/Sources/DocCHTML/CMakeLists.txt b/Sources/DocCHTML/CMakeLists.txt index 531ccff0b..67798d1ab 100644 --- a/Sources/DocCHTML/CMakeLists.txt +++ b/Sources/DocCHTML/CMakeLists.txt @@ -11,6 +11,7 @@ add_library(DocCHTML STATIC LinkProvider.swift MarkdownRenderer+Availability.swift MarkdownRenderer+Breadcrumbs.swift + MarkdownRenderer+Declaration.swift MarkdownRenderer+Parameters.swift MarkdownRenderer.swift WordBreak.swift diff --git a/Sources/DocCHTML/MarkdownRenderer+Declaration.swift b/Sources/DocCHTML/MarkdownRenderer+Declaration.swift new file mode 100644 index 000000000..c38897263 --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer+Declaration.swift @@ -0,0 +1,75 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 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 + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +#if canImport(FoundationXML) +// TODO: Consider other HTML rendering options as a future improvement (rdar://165755530) +package import FoundationXML +#else +package import Foundation +#endif + +package import DocCCommon +package import SymbolKit + +package extension MarkdownRenderer { + + typealias DeclarationFragment = SymbolGraph.Symbol.DeclarationFragments.Fragment + + /// Creates a`
` HTML element hierarchy that represents the symbol's language-specific declarations.
+    ///
+    /// When the renderer has a ``RenderGoal/richness`` goal, it creates a `` element for each declaration fragment so that to enable syntax highlighting.
+    ///
+    /// When the renderer has a ``RenderGoal/conciseness`` goal, it joins the different fragments into string.
+    func declaration(_ fragmentsByLanguage: [SourceLanguage: [DeclarationFragment]]) -> XMLElement {
+        let fragmentsByLanguage = RenderHelpers.sortedLanguageSpecificValues(fragmentsByLanguage)
+        
+        guard goal == .richness else {
+            // If the goal is conciseness, display only the primary language's plain text declaration
+            let plainTextDeclaration: [XMLNode] = fragmentsByLanguage.first.map { _, fragments in
+                [.element(named: "code", children: [.text(fragments.map(\.spelling).joined())])]
+            } ?? []
+            return .element(named: "pre", children: plainTextDeclaration)
+        }
+        
+        let declarations: [XMLElement] = if fragmentsByLanguage.count == 1 {
+            // If there's only a single language there's no need to mark anything as language specific.
+            [XMLNode.element(named: "code", children: _declarationTokens(for: fragmentsByLanguage.first!.value))]
+        } else {
+            fragmentsByLanguage.map { language, fragments in
+                XMLNode.element(named: "code", children: _declarationTokens(for: fragments), attributes: ["class": "\(language.id)-only"])
+            }
+        }
+        return .element(named: "pre", children: declarations, attributes: ["id": "declaration"])
+    }
+    
+    private func _declarationTokens(for fragments: [DeclarationFragment]) -> [XMLNode] {
+        // TODO: Pretty print declarations for Swift and Objective-C by placing attributes and parameters on their own lines (rdar://165918402)
+        fragments.map { fragment in
+            let elementClass = "token-\(fragment.kind.rawValue)"
+            
+            if fragment.kind == .typeIdentifier,
+               let symbolID = fragment.preciseIdentifier,
+               let reference = linkProvider.pathForSymbolID(symbolID)
+            {
+                // If the token refers to a symbol that the `linkProvider` is aware of, make that fragment a link to that symbol.
+                return .element(named: "a", children: [.text(fragment.spelling)], attributes: [
+                    "href": path(to: reference),
+                    "class": elementClass
+                ])
+            } else if fragment.kind == .text {
+                // ???: Does text also need a  element or can that be avoided?
+                return .text(fragment.spelling)
+            } else {
+                // The declaration element is expected to scroll, so individual fragments don't need to contain explicit word breaks.
+                return .element(named: "span", children: [.text(fragment.spelling)], attributes: ["class": elementClass])
+            }
+        }
+    }
+}
diff --git a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift
index 2ab9c7cd5..5907ed10a 100644
--- a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift
+++ b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift
@@ -269,6 +269,159 @@ struct MarkdownRenderer_PageElementsTests {
         """)
     }
     
+    @Test(arguments: RenderGoal.allCases)
+    func testRenderSwiftDeclaration(goal: RenderGoal) {
+        let symbolPaths = [
+            "first-parameter-symbol-id":  URL(string: "/documentation/ModuleName/FirstParameterValue/index.html")!,
+            "second-parameter-symbol-id": URL(string: "/documentation/ModuleName/SecondParameterValue/index.html")!,
+            "return-value-symbol-id":     URL(string: "/documentation/ModuleName/ReturnValue/index.html")!,
+        ]
+        
+        let declaration = makeRenderer(goal: goal, pathsToReturn: symbolPaths).declaration([
+            .swift:  [
+                .init(kind: .keyword,           spelling: "func",        preciseIdentifier: nil),
+                .init(kind: .text,              spelling: " ",           preciseIdentifier: nil),
+                .init(kind: .identifier,        spelling: "doSomething", preciseIdentifier: nil),
+                .init(kind: .text,              spelling: "(",           preciseIdentifier: nil),
+                .init(kind: .externalParameter, spelling: "with",        preciseIdentifier: nil),
+                .init(kind: .text,              spelling: " ",           preciseIdentifier: nil),
+                .init(kind: .internalParameter, spelling: "first",       preciseIdentifier: nil),
+                .init(kind: .text,              spelling: ": ",          preciseIdentifier: nil),
+                .init(kind: .typeIdentifier,    spelling: "FirstParameterValue", preciseIdentifier: "first-parameter-symbol-id"),
+                .init(kind: .text,              spelling: ", ",          preciseIdentifier: nil),
+                .init(kind: .externalParameter, spelling: "and",         preciseIdentifier: nil),
+                .init(kind: .text,              spelling: " ",           preciseIdentifier: nil),
+                .init(kind: .internalParameter, spelling: "second",      preciseIdentifier: nil),
+                .init(kind: .text,              spelling: ": ",          preciseIdentifier: nil),
+                .init(kind: .typeIdentifier,    spelling: "SecondParameterValue", preciseIdentifier: "second-parameter-symbol-id"),
+                .init(kind: .text,              spelling: ") ",          preciseIdentifier: nil),
+                .init(kind: .keyword,           spelling: "throws",      preciseIdentifier: nil),
+                .init(kind: .text,              spelling: "-> ",         preciseIdentifier: nil),
+                .init(kind: .typeIdentifier,    spelling: "ReturnValue", preciseIdentifier: "return-value-symbol-id"),
+            ]
+        ])
+        switch goal {
+        case .richness:
+            declaration.assertMatches(prettyFormatted: true, expectedXMLString: """
+            
+            
+              func
+               doSomething
+              (with
+               first
+              : FirstParameterValue
+              , and
+               second
+              : SecondParameterValue
+              ) throws
+              -> ReturnValue
+            
+            
+ """) + case .conciseness: + declaration.assertMatches(prettyFormatted: true, expectedXMLString: """ +
+              func doSomething(with first: FirstParameterValue, and second: SecondParameterValue) throws-> ReturnValue
+            
+ """) + } + } + + @Test(arguments: RenderGoal.allCases) + func testRenderLanguageSpecificDeclarations(goal: RenderGoal) { + let symbolPaths = [ + "first-parameter-symbol-id": URL(string: "/documentation/ModuleName/FirstParameterValue/index.html")!, + "second-parameter-symbol-id": URL(string: "/documentation/ModuleName/SecondParameterValue/index.html")!, + "return-value-symbol-id": URL(string: "/documentation/ModuleName/ReturnValue/index.html")!, + "error-parameter-symbol-id": URL(string: "/documentation/Foundation/NSError/index.html")!, + ] + + let declaration = makeRenderer(goal: goal, pathsToReturn: symbolPaths).declaration([ + .swift: [ + .init(kind: .keyword, spelling: "func", preciseIdentifier: nil), + .init(kind: .text, spelling: " ", preciseIdentifier: nil), + .init(kind: .identifier, spelling: "doSomething", preciseIdentifier: nil), + .init(kind: .text, spelling: "(", preciseIdentifier: nil), + .init(kind: .externalParameter, spelling: "with", preciseIdentifier: nil), + .init(kind: .text, spelling: " ", preciseIdentifier: nil), + .init(kind: .internalParameter, spelling: "first", preciseIdentifier: nil), + .init(kind: .text, spelling: ": ", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "FirstParameterValue", preciseIdentifier: "first-parameter-symbol-id"), + .init(kind: .text, spelling: ", ", preciseIdentifier: nil), + .init(kind: .externalParameter, spelling: "and", preciseIdentifier: nil), + .init(kind: .text, spelling: " ", preciseIdentifier: nil), + .init(kind: .internalParameter, spelling: "second", preciseIdentifier: nil), + .init(kind: .text, spelling: ": ", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "SecondParameterValue", preciseIdentifier: "second-parameter-symbol-id"), + .init(kind: .text, spelling: ") ", preciseIdentifier: nil), + .init(kind: .keyword, spelling: "throws", preciseIdentifier: nil), + .init(kind: .text, spelling: "-> ", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "ReturnValue", preciseIdentifier: "return-value-symbol-id"), + ], + + .objectiveC: [ + .init(kind: .text, spelling: "- (", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "ReturnValue", preciseIdentifier: "return-value-symbol-id"), + .init(kind: .text, spelling: ") ", preciseIdentifier: nil), + .init(kind: .identifier, spelling: "doSomethingWithFirst", preciseIdentifier: nil), + .init(kind: .text, spelling: ": (", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "FirstParameterValue", preciseIdentifier: "first-parameter-symbol-id"), + .init(kind: .text, spelling: ") ", preciseIdentifier: nil), + .init(kind: .internalParameter, spelling: "first", preciseIdentifier: nil), + .init(kind: .text, spelling: " ", preciseIdentifier: nil), + .init(kind: .identifier, spelling: "andSecond", preciseIdentifier: nil), + .init(kind: .text, spelling: ": (", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "SecondParameterValue", preciseIdentifier: "second-parameter-symbol-id"), + .init(kind: .text, spelling: ") ", preciseIdentifier: nil), + .init(kind: .internalParameter, spelling: "second", preciseIdentifier: nil), + .init(kind: .text, spelling: " ", preciseIdentifier: nil), + .init(kind: .identifier, spelling: "error", preciseIdentifier: nil), + .init(kind: .text, spelling: ": (", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "NSError", preciseIdentifier: "error-parameter-symbol-id"), + .init(kind: .text, spelling: " **) ", preciseIdentifier: nil), + .init(kind: .internalParameter, spelling: "error", preciseIdentifier: nil), + .init(kind: .text, spelling: ";", preciseIdentifier: nil), + ] + ]) + switch goal { + case .richness: + declaration.assertMatches(prettyFormatted: true, expectedXMLString: """ +
+            
+              func
+               doSomething
+              (with
+               first
+              : FirstParameterValue
+              , and
+               second
+              : SecondParameterValue
+              ) throws
+              -> ReturnValue
+            
+            - (ReturnValue
+              ) doSomethingWithFirst
+              : (FirstParameterValue
+              ) first
+               andSecond
+              : (SecondParameterValue
+              ) second
+               error
+              : (NSError
+               **) error
+              ;
+            
+ """) + + case .conciseness: + declaration.assertMatches(prettyFormatted: true, expectedXMLString: """ +
+              func doSomething(with first: FirstParameterValue, and second: SecondParameterValue) throws-> ReturnValue
+            
+ """) + } + } + // MARK: - private func makeRenderer(