Repository: apple/swift-docc (swift)

Issue:
`@DeprecationSummary` directive doesn't always have an effect
### Description

When specifying the `@DeprecationSummary` directive in a symbol extension markdown file, the directive doesn't take effect if the symbol has documentation.

If you define a symbol with multiple lines of source code documentation, and then attempt to deprecate the symbol from a symbol extension markdown file, the result is that the symbol is not deprecated as expected.

### Checklist

- [X] If possible, I've reproduced the issue using the `main` branch of this package.
- [X] This issue hasn't been addressed in an [existing GitHub issue](https://github.com/apple/swift-docc/issues).

### Expected Behavior

I expected the symbol `ExampleDeprecated` to be marked as deprecated in my documentation, like this:
<img width="769" alt="Screenshot 2024-05-29 at 16 14 45" src="https://github.com/apple/swift-docc/assets/15234535/a221d70d-078f-44bf-91d9-d7bb628ef358">

### Actual behavior

But in actuality, the `@DeprecationSummary` directive has no effect when using the default merge behaviour (`append`), and the result is the following:
<img width="808" alt="Screenshot 2024-05-29 at 16 12 33" src="https://github.com/apple/swift-docc/assets/15234535/624c1810-8d0d-45f8-ba51-d66470899a8b">

I can only get the `@DeprecationSummary` directive to have an effect when setting the merge behaviour to be `override`:
```md
@Metadata {
    @DocumentationExtension(mergeBehavior: override)
}
```

### Steps To Reproduce

1. Define a symbol and add multiple lines of documentation in the source code, like so:
```swift
/// I am adding some amazing documentation for my new struct.
///
/// ## Overview
///
/// In fact it's so cool, that it even needs some markdown.
public struct ExampleDeprecated {}
```
2. Deprecate the symbol from a symbol extension markdown file:
```md
# ``ExampleDeprecated``

@DeprecationSummary {
 This is my deprecation text.
}

This struct is deprecated because I needed an example.
```

### Swift-DocC Version Information

037f0bcca7b1aa75551bd9b46825287c159a86b5

### Swift Compiler Version Information

```shell
❯ swiftc --version
swift-driver version: 1.90.11.1 Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
Target: arm64-apple-macosx15.0
```

Pull request description:
Bug/issue #, if applicable: Closes #932, rdar://70056350.

## Summary

Fixes a bug where the `@DeprecationSummary` directive would have no effect if a symbol had multiple lines of documentation comments in its source, like so:
```swift
/// I am adding some amazing documentation for my new struct.
///
/// ## Overview
///
/// In fact it's so cool, that it even needs some markdown.
public struct ExampleDeprecated {}
```

The root cause of the issue was that the `@DeprecatedSummary` directive would only take effect when used in the "Abstract" section of the markup.

When using the default merging strategy for documentation extension files (`append`), documentation extension content is added after all the existing documentation comments, resulting in something like:
```md
I am adding some amazing documentation for my new struct.

## Overview

In fact it's so cool, that it even needs some markdown.

@DeprecationSummary {
    This symbol is deprecated.
}
```

This would make the `@DeprecationSummary` directive be in the Discussion section, not the Abstract, so it would be ignored. This made it impossible to override the deprecation summary from the documentation extension file for any symbols which had multiple lines of documentation comments. (It worked fine if the symbol has no source documentation or only one line of source documentation).

This PR makes it so that the `@DeprecationSummary` can be parsed in both the Abstract and Discussion sections.

## Dependencies

N/A.

## Testing

You need the following setup:
- A symbol with multiple lines of documentation in the source code
- A documentation extension file which uses the `@DeprecationSummary` directive, for example.

Example DocC bundle:
[TestPackage.docc.zip](https://github.com/user-attachments/files/16177976/TestPackage.docc.zip)

Steps:
1. Download example bundle
2. Run `docc preview TestPackage.docc`
3. Go to http://localhost:8080/documentation/testpackage/exampledeprecated and verify that the symbol is marked as deprecated.

## Checklist

Make sure you check off the following items. If they cannot be completed, provide a reason.

- [x] Added tests
- [x] Ran the `./bin/test` script and it succeeded
- ~~[ ] Updated documentation if necessary~~ **Not necessary**

### Verification

**Unit testing**
Added unit tests to `DocumentationMarkupTests` and `DeprecationSummaryTests `.

```
[1703/1703] Testing SwiftDocCTests.SynchronizationTests/testBlockingWithLock
=> Checking for unacceptable language… okay.
=> Checking license headers… okay.
=> Validating scripts in bin subdirectory… okay
```

**Site rendering**

Before                                  |  After
:-------------------------:|:-------------------------:
<img width="1283" alt="Screenshot 2024-07-11 at 2 50 47 PM" src="https://github.com/swiftlang/swift-docc/assets/15234535/4547a758-d2dd-42e4-a5c6-fc260a4d9e04"> | <img width="1283" alt="Screenshot 2024-07-11 at 2 51 23 PM" src="https://github.com/swiftlang/swift-docc/assets/15234535/d2b79152-9326-4f10-864c-514c8071b8ea">

Patch:
diff --git a/Sources/SwiftDocC/Model/DocumentationMarkup.swift b/Sources/SwiftDocC/Model/DocumentationMarkup.swift
index fa5a90dde2..c77efe12f4 100644
--- a/Sources/SwiftDocC/Model/DocumentationMarkup.swift
+++ b/Sources/SwiftDocC/Model/DocumentationMarkup.swift
@@ -74,12 +74,17 @@ struct DocumentationMarkup {
     }
     
     /// Directives which are removed from the markdown content after being parsed.
-    private static let directivesRemovedFromContent = [
+    static let directivesRemovedFromContent = [
         Comment.directiveName,
         Metadata.directiveName,
         Options.directiveName,
         Redirect.directiveName,
     ]
+    
+    private static let allowedSectionsForDeprecationSummary = [
+        ParserSection.abstract,
+        ParserSection.discussion,
+    ]
 
     // MARK: - Parsed Data
     
@@ -144,17 +149,21 @@ struct DocumentationMarkup {
                 }
             }
             
+            // The deprecation summary directive is allowed to have an effect in multiple sections of the content.
+            if let directive = child as? BlockDirective,
+               directive.name == DeprecationSummary.directiveName,
+               Self.allowedSectionsForDeprecationSummary.contains(currentSection) {
+                deprecation = MarkupContainer(directive.children)
+                return
+            }
+            
             // Parse an abstract, if found
             if currentSection == .abstract {
                 if abstractSection == nil, let firstParagraph = child as? Paragraph {
                     abstractSection = AbstractSection(paragraph: firstParagraph)
                     return
                 } else if let directive = child as? BlockDirective {
-                    if directive.name == DeprecationSummary.directiveName {
-                        // Found deprecation notice in the abstract.
-                        deprecation = MarkupContainer(directive.children)
-                        return
-                    } else if Self.directivesRemovedFromContent.contains(directive.name) {
+                    if Self.directivesRemovedFromContent.contains(directive.name) {
                         // These directives don't affect content so they shouldn't break us out of
                         // the automatic abstract section.
                         return


Test patch:
diff --git a/Tests/SwiftDocCTests/Model/DocumentationMarkupTests.swift b/Tests/SwiftDocCTests/Model/DocumentationMarkupTests.swift
index 53fad646ce..9cea875592 100644
--- a/Tests/SwiftDocCTests/Model/DocumentationMarkupTests.swift
+++ b/Tests/SwiftDocCTests/Model/DocumentationMarkupTests.swift
@@ -89,8 +89,28 @@ class DocumentationMarkupTests: XCTestCase {
             XCTAssertNil(model.abstractSection)
             XCTAssertEqual(expected, model.discussionSection?.content.map({ $0.detachedFromParent.debugDescription() }).joined(separator: "\n"))
         }
+        
+        // Directives which shouldn't break us out of the automatic abstract section.
+        for allowedDirective in DocumentationMarkup.directivesRemovedFromContent {
+            do {
+                let source = """
+                # Title
+                @\(allowedDirective)
+                My abstract __content__.
+                """
+                let expected = """
+                Text "My abstract "
+                Strong
+                └─ Text "content"
+                Text "."
+                """
+                let model = DocumentationMarkup(markup: Document(parsing: source, options: .parseBlockDirectives))
+                XCTAssertEqual(expected, model.abstractSection?.content.map({ $0.detachedFromParent.debugDescription() }).joined(separator: "\n"))
+                XCTAssertNil(model.discussionSection)
+            }
+        }
 
-        // Directives in between sections
+        // Directives in between sections should go into the discussion section.
         do {
             let source = """
             # Title
@@ -328,6 +348,48 @@ class DocumentationMarkupTests: XCTestCase {
               Deprecated!
             }
             """
+            let expected = """
+            Deprecated!
+            """
+            let model = DocumentationMarkup(markup: Document(parsing: source, options: .parseBlockDirectives))
+            XCTAssertEqual(expected, model.deprecation?.elements.map({ $0.format() }).joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines))
+        }
+        
+        // Deprecation in the topics
+        do {
+            let source = """
+            # Title
+            My abstract __content__.
+            
+            Discussion __content__.
+            ## Topics
+            
+            @DeprecationSummary {
+              Deprecated!
+            }
+            
+            ### Basics
+             - <doc:link>
+            """
+            let model = DocumentationMarkup(markup: Document(parsing: source, options: .parseBlockDirectives))
+            XCTAssertNil(model.deprecation)
+        }
+        
+        // Deprecation in the SeeAlso
+        do {
+            let source = """
+            # Title
+            My abstract __content__.
+            
+            Discussion __content__.
+            ## See Also
+            
+            @DeprecationSummary {
+              Deprecated!
+            }
+            
+             - <doc:link>
+            """
             let model = DocumentationMarkup(markup: Document(parsing: source, options: .parseBlockDirectives))
             XCTAssertNil(model.deprecation)
         }
diff --git a/Tests/SwiftDocCTests/Rendering/DeprecationSummaryTests.swift b/Tests/SwiftDocCTests/Rendering/DeprecationSummaryTests.swift
index 86599defd0..469adb1595 100644
--- a/Tests/SwiftDocCTests/Rendering/DeprecationSummaryTests.swift
+++ b/Tests/SwiftDocCTests/Rendering/DeprecationSummaryTests.swift
@@ -14,9 +14,9 @@ import XCTest
 
 class DeprecationSummaryTests: XCTestCase {
     func testDecodeDeprecatedSymbol() throws {
-        let deprecatedSymbolURL = Bundle.module.url(
+        let deprecatedSymbolURL = try XCTUnwrap(Bundle.module.url(
             forResource: "deprecated-symbol", withExtension: "json",
-            subdirectory: "Rendering Fixtures")!
+            subdirectory: "Rendering Fixtures"))
         
         let data = try Data(contentsOf: deprecatedSymbolURL)
         let symbol = try RenderNode.decode(fromJSON: data)
@@ -35,14 +35,10 @@ class DeprecationSummaryTests: XCTestCase {
         let node = try context.entity(with: ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/SideKit/SideClass/init()", sourceLanguage: .swift))
         
         // Compile docs and verify contents
-        let symbol = node.semantic as! Symbol
+        let symbol = try XCTUnwrap(node.semantic as? Symbol)
         var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: node.reference)
         
-        guard let renderNode = translator.visit(symbol) as? RenderNode else {
-            XCTFail("Could not compile the node")
-            return
-        }
-        
+        let renderNode = try XCTUnwrap(translator.visit(symbol) as? RenderNode, "Could not compile the node")
         XCTAssertEqual(renderNode.deprecationSummary?.firstParagraph, [.text("This initializer has been deprecated.")])
     }
 
@@ -70,14 +66,10 @@ class DeprecationSummaryTests: XCTestCase {
         let node = try context.entity(with: ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/SideKit/SideClass", sourceLanguage: .swift))
         
         // Compile docs and verify contents
-        let symbol = node.semantic as! Symbol
+        let symbol = try XCTUnwrap(node.semantic as? Symbol)
         var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: node.reference)
         
-        guard let renderNode = translator.visit(symbol) as? RenderNode else {
-            XCTFail("Could not compile the node")
-            return
-        }
-        
+        let renderNode = try XCTUnwrap(translator.visit(symbol) as? RenderNode, "Could not compile the node")
         XCTAssertEqual(renderNode.deprecationSummary?.firstParagraph, [.text("This class has been deprecated.")])
         
         // Verify that the in-abstract directive didn't make the context overflow into the discussion
@@ -98,7 +90,7 @@ class DeprecationSummaryTests: XCTestCase {
         )
 
         // Compile docs and verify contents
-        let symbol = node.semantic as! Symbol
+        let symbol = try XCTUnwrap(node.semantic as? Symbol)
         var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: node.reference)
 
         guard let renderNode = translator.visit(symbol) as? RenderNode else {
@@ -106,7 +98,7 @@ class DeprecationSummaryTests: XCTestCase {
             return
         }
 
-        let expected: [RenderInlineContent] = [
+        XCTAssertEqual(renderNode.deprecationSummary?.firstParagraph, [
             .text("Use the "),
             SwiftDocC.RenderInlineContent.reference(
                 identifier: SwiftDocC.RenderReferenceIdentifier("doc://org.swift.docc.example/documentation/CoolFramework/CoolClass/coolFunc()"),
@@ -116,9 +108,7 @@ class DeprecationSummaryTests: XCTestCase {
             ),
             SwiftDocC.RenderInlineContent.text(" "),
             SwiftDocC.RenderInlineContent.text("initializer instead."),
-        ]
-
-        XCTAssertEqual(renderNode.deprecationSummary?.firstParagraph, expected)
+        ])
     }
     
     func testSymbolDeprecatedSummary() throws {
@@ -132,20 +122,15 @@ class DeprecationSummaryTests: XCTestCase {
         )
         
         // Compile docs and verify contents
-        let symbol = node.semantic as! Symbol
+        let symbol = try XCTUnwrap(node.semantic as? Symbol)
         var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: node.reference)
         
-        guard let renderNode = translator.visit(symbol) as? RenderNode else {
-            XCTFail("Could not compile the node")
-            return
-        }
-        
+        let renderNode = try XCTUnwrap(translator.visit(symbol) as? RenderNode, "Could not compile the node")
+
         // `doUncoolThings(with:)` has a blanket deprecation notice from the class, but no curated article - verify that the deprecation notice from the class still shows up on the rendered page
-        let expected: [RenderInlineContent] = [
+        XCTAssertEqual(renderNode.deprecationSummary?.firstParagraph, [
             .text("This class is deprecated."),
-        ]
-        
-        XCTAssertEqual(renderNode.deprecationSummary?.firstParagraph, expected)
+        ])
     }
   
   func testDeprecationOverride() throws {
@@ -159,26 +144,72 @@ class DeprecationSummaryTests: XCTestCase {
       )
       
       // Compile docs and verify contents
-      let symbol = node.semantic as! Symbol
+      let symbol = try XCTUnwrap(node.semantic as? Symbol)
       var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: node.reference)
       
-      guard let renderNode = translator.visit(symbol) as? RenderNode else {
-          XCTFail("Could not compile the node")
-          return
-      }
-      
+      let renderNode = try XCTUnwrap(translator.visit(symbol) as? RenderNode, "Could not compile the node")
+
       // `init()` has deprecation information in both the symbol graph and the documentation extension; when there are extra headings in an extension file, we need to make sure we correctly parse out the deprecation message from the extension and display that
-      let expected: [RenderInlineContent] = [
-          .text("Use the "),
-          .reference(
-              identifier: SwiftDocC.RenderReferenceIdentifier("doc://org.swift.docc.example/documentation/CoolFramework/CoolClass/init(config:cache:)"),
-              isActive: true,
-              overridingTitle: nil,
-              overridingTitleInlineContent: nil
-          ),
-          .text(" initializer instead."),
-      ]
-      
-      XCTAssertEqual(renderNode.deprecationSummary?.firstParagraph, expected)
+      XCTAssertEqual(renderNode.deprecationSummary?.firstParagraph, [
+        .text("Use the "),
+        .reference(
+            identifier: SwiftDocC.RenderReferenceIdentifier("doc://org.swift.docc.example/documentation/CoolFramework/CoolClass/init(config:cache:)"),
+            isActive: true,
+            overridingTitle: nil,
+            overridingTitleInlineContent: nil
+        ),
+        .text(" initializer instead."),
+    ])
   }
+    
+    func testDeprecationSummaryInDiscussionSection() throws {
+        let (bundle, context) = try testBundleAndContext(named: "BundleWithLonelyDeprecationDirective")
+        let node = try context.entity(
+            with: ResolvedTopicReference(
+                bundleIdentifier: bundle.identifier,
+                path: "/documentation/CoolFramework/CoolClass/coolFunc()",
+                sourceLanguage: .swift
+            )
+        )
+        
+        // Compile docs and verify contents
+        let symbol = try XCTUnwrap(node.semantic as? Symbol)
+        var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: node.reference)
+        
+        let renderNode = try XCTUnwrap(translator.visit(symbol) as? RenderNode, "Could not compile the node")
+
+        // `coolFunc()` has deprecation information in both the symbol graph and the documentation extension; the deprecation information is part of the "Overview" section of the markup but it should still be parsed as expected.
+        XCTAssertEqual(renderNode.deprecationSummary?.firstParagraph, [
+            .text("Use the "),
+            .reference(
+                identifier: SwiftDocC.RenderReferenceIdentifier("doc://org.swift.docc.example/documentation/CoolFramework/CoolClass/init()"),
+                isActive: true,
+                overridingTitle: nil,
+                overridingTitleInlineContent: nil
+            ),
+            .text(" initializer instead."),
+        ])
+    }
+    
+    func testDeprecationSummaryWithMultiLineCommentSymbol() throws {
+        let (bundle, context) = try testBundleAndContext(named: "BundleWithLonelyDeprecationDirective")
+        let node = try context.entity(
+            with: ResolvedTopicReference(
+                bundleIdentifier: bundle.identifier,
+                path: "/documentation/CoolFramework/CoolClass/init(config:cache:)",
+                sourceLanguage: .swift
+            )
+        )
+        
+        // Compile docs and verify contents
+        let symbol = try XCTUnwrap(node.semantic as? Symbol)
+        var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: node.reference)
+        
+        let renderNode = try XCTUnwrap(translator.visit(symbol) as? RenderNode, "Could not compile the node")
+        
+        // `init(config:cache:)` has deprecation information in both the symbol graph and the documentation extension; the symbol graph has multiple lines of documentation comments for the function, but adding deprecation information in the documentation extension should still work.
+        XCTAssertEqual(renderNode.deprecationSummary?.firstParagraph, [
+            .text("This initializer is deprecated as of version 1.0.0."),
+        ])
+    }
 }
diff --git a/Tests/SwiftDocCTests/Test Bundles/BundleWithLonelyDeprecationDirective.docc/CoolClass.coolFunc.md b/Tests/SwiftDocCTests/Test Bundles/BundleWithLonelyDeprecationDirective.docc/CoolClass.coolFunc.md
new file mode 100644
index 0000000000..3593bd928f
--- /dev/null
+++ b/Tests/SwiftDocCTests/Test Bundles/BundleWithLonelyDeprecationDirective.docc/CoolClass.coolFunc.md
@@ -0,0 +1,13 @@
+# ``CoolFramework/CoolClass/coolFunc()``
+
+This is a very cool (yet deprecated) function.
+
+## Overview
+
+We can also deprecate anywhere in the discussion section.
+
+@DeprecationSummary {
+  Use the ``CoolClass/init()`` initializer instead.
+}
+
+<!-- Copyright (c) 2024 Apple Inc and the Swift Project authors. All Rights Reserved. -->
diff --git a/Tests/SwiftDocCTests/Test Bundles/BundleWithLonelyDeprecationDirective.docc/CoolClass.initConfigCache.md b/Tests/SwiftDocCTests/Test Bundles/BundleWithLonelyDeprecationDirective.docc/CoolClass.initConfigCache.md
new file mode 100644
index 0000000000..0ec30d0ab2
--- /dev/null
+++ b/Tests/SwiftDocCTests/Test Bundles/BundleWithLonelyDeprecationDirective.docc/CoolClass.initConfigCache.md
@@ -0,0 +1,9 @@
+# ``CoolFramework/CoolClass/init(config:cache:)``
+
+@DeprecationSummary {
+    This initializer is deprecated as of version 1.0.0.
+}
+
+Overriding the deprecation summary of a symbol that has multiple lines of documentation comments also works!
+
+<!-- Copyright (c) 2024 Apple Inc and the Swift Project authors. All Rights Reserved. -->