From 7132d3138722a63dccc70f3f226599580b448d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Tue, 25 Apr 2023 11:54:17 -0700 Subject: [PATCH 1/7] Use link resolution to match documentation extensions with symbols Also, fix incorrect source url in technology root diagnostic rdar://108392613 --- .../Infrastructure/DocumentationContext.swift | 111 +++++++++++++---- .../DocumentationContext+RootPageTests.swift | 10 +- .../DocumentationContextTests.swift | 113 ++++++++++++++++++ 3 files changed, 206 insertions(+), 28 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index 8f07f352df..ca4d17ee82 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -823,7 +823,8 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { technologies: [SemanticResult], tutorials: [SemanticResult], tutorialArticles: [SemanticResult], - articles: [SemanticResult
] + articles: [SemanticResult
], + documentationExtensions: [SemanticResult
] ) { // First, try to understand the basic structure of the document by // analyzing it and putting references in as "unresolved". @@ -831,6 +832,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { var tutorials = [SemanticResult]() var tutorialArticles = [SemanticResult]() var articles = [SemanticResult
]() + var documentationExtensions = [SemanticResult
]() var references: [ResolvedTopicReference: URL] = [:] @@ -937,25 +939,21 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { // Separate articles that look like documentation extension files from other articles, so that the documentation extension files can be matched up with a symbol. // At this point we consider all articles with an H1 containing link "documentation extension" - some links might not resolve in the final documentation hierarchy // and we will emit warnings for those later on when we finalize the bundle discovery phase. - if let link = result.value.title?.child(at: 0) as? AnyLink, - let url = link.destination.flatMap(ValidatedURL.init(parsingExact:)) { - let reference = result.topicGraphNode.reference - - let symbolPath = NodeURLGenerator.Path.documentation(path: url.components.path).stringValue - let symbolReference = ResolvedTopicReference( - bundleIdentifier: reference.bundleIdentifier, - path: symbolPath, - fragment: nil, - sourceLanguages: reference.sourceLanguages - ) - - uncuratedDocumentationExtensions[symbolReference, default: []].append(result) + if result.value.title?.child(at: 0) is AnyLink { + documentationExtensions.append(result) // Warn for an incorrect root page metadata directive. - if result.value.metadata?.technologyRoot != nil { - let diagnostic = Diagnostic(source: url.url, severity: .warning, range: article.metadata?.technologyRoot?.originalMarkup.range, identifier: "org.swift.docc.UnexpectedTechnologyRoot", summary: "Don't use TechnologyRoot in documentation extension files because it's only valid as a directive in articles") - let problem = Problem(diagnostic: diagnostic, possibleSolutions: []) - diagnosticEngine.emit(problem) + if let technologyRoot = result.value.metadata?.technologyRoot { + let diagnostic = Diagnostic(source: url, severity: .warning, range: article.metadata?.technologyRoot?.originalMarkup.range, identifier: "org.swift.docc.UnexpectedTechnologyRoot", summary: "Documentation extension files can't become technology roots.") + let solutions: [Solution] + if let range = technologyRoot.originalMarkup.range { + solutions = [ + Solution(summary: "Remove the TechnologyRoot directive", replacements: [Replacement(range: range, replacement: "")]) + ] + } else { + solutions = [] + } + diagnosticEngine.emit(Problem(diagnostic: diagnostic, possibleSolutions: solutions)) } } else { precondition(uncuratedArticles[result.topicGraphNode.reference] == nil, "Article references are unique.") @@ -976,7 +974,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { } } - return (technologies, tutorials, tutorialArticles, articles) + return (technologies, tutorials, tutorialArticles, articles, documentationExtensions) } private func insertLandmarks(_ landmarks: Landmarks, from topicGraphNode: TopicGraph.Node, source url: URL) where Landmarks.Element == Landmark { @@ -1154,7 +1152,12 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { /// /// - Parameter bundle: The bundle to load symbol graph files from. /// - Returns: A pair of the references to all loaded modules and the hierarchy of all the loaded symbol's references. - private func registerSymbols(from bundle: DocumentationBundle, symbolGraphLoader: SymbolGraphLoader) throws -> (moduleReferences: Set, urlHierarchy: BidirectionalTree) { + private func registerSymbols( + from bundle: DocumentationBundle, + symbolGraphLoader: SymbolGraphLoader, + documentationExtensions: [SemanticResult
] + ) throws -> (moduleReferences: Set, urlHierarchy: BidirectionalTree) + { // Making sure that we correctly let decoding memory get released, do not remove the autorelease pool. return try autoreleasepool { [documentationCacheBasedLinkResolver] () -> (Set, BidirectionalTree) in /// A tree of the symbol hierarchy as defined by the combined symbol graph. @@ -1313,6 +1316,65 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { if LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver { hierarchyBasedLinkResolver!.addMappingForSymbols(symbolIndex: symbolIndex) } + for documentationExtension in documentationExtensions { + guard let link = documentationExtension.value.title?.child(at: 0) as? AnyLink else { + fatalError("An article shouldn't have ended up in the documentation extension list unless its title was a link. File: \(documentationExtension.source.absoluteString.singleQuoted)") + } + + guard let destination = link.destination else { + let diagnostic = Diagnostic(source: documentationExtension.source, severity: .warning, range: link.range, identifier: "org.swift.docc.emptyLinkDestination", summary: """ + Documentation extension with an empty link doesn't correspond to any symbol. + """, explanation: nil, notes: []) + diagnosticEngine.emit(Problem(diagnostic: diagnostic)) + continue + } + guard let url = ValidatedURL(parsingExact: destination) else { + let diagnostic = Diagnostic(source: documentationExtension.source, severity: .warning, range: link.range, identifier: "org.swift.docc.invalidLinkDestination", summary: """ + \(destination.singleQuoted) is + """, explanation: nil, notes: []) + diagnosticEngine.emit(Problem(diagnostic: diagnostic)) + continue + } + + if LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver { + // TODO: Resolve the link relative to the module https://github.com/apple/swift-docc/issues/516 + let reference = TopicReference.unresolved(.init(topicURL: url)) + switch resolve(reference, in: bundle.rootReference, fromSymbolLink: true) { + case .success(let resolved): + uncuratedDocumentationExtensions[resolved, default: []].append(documentationExtension) + case .failure(_, _): + // TODO: Warn that the link didn't match any symbol https://github.com/apple/swift-docc/issues/560 + + // Fallback to the documentation cache based link resolver's behavior. + let reference = documentationExtension.topicGraphNode.reference + + let symbolPath = NodeURLGenerator.Path.documentation(path: url.components.path).stringValue + let symbolReference = ResolvedTopicReference( + bundleIdentifier: reference.bundleIdentifier, + path: symbolPath, + fragment: nil, + sourceLanguages: reference.sourceLanguages + ) + + uncuratedDocumentationExtensions[symbolReference, default: []].append(documentationExtension) + continue + } + } else { + // The documentation cache based link resolver doesn't "resolve" the links in the documentation extension titles. + // Instead it matches them by exact match or not at all. + let reference = documentationExtension.topicGraphNode.reference + + let symbolPath = NodeURLGenerator.Path.documentation(path: url.components.path).stringValue + let symbolReference = ResolvedTopicReference( + bundleIdentifier: reference.bundleIdentifier, + path: symbolPath, + fragment: nil, + sourceLanguages: reference.sourceLanguages + ) + + uncuratedDocumentationExtensions[symbolReference, default: []].append(documentationExtension) + } + } if hierarchyBasedLinkResolver == nil || LinkResolutionMigrationConfiguration.shouldReportLinkResolutionMismatches { // The `symbolsURLHierarchy` is only used in the cache-based link resolver to traverse the documentation and @@ -2157,7 +2219,8 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { technologies: [SemanticResult], tutorials: [SemanticResult], tutorialArticles: [SemanticResult], - articles: [SemanticResult
] + articles: [SemanticResult
], + documentationExtensions: [SemanticResult
] )! discoveryGroup.async(queue: discoveryQueue) { [unowned self] in @@ -2181,10 +2244,10 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { } // All discovery went well, process the inputs. - let (technologies, tutorials, tutorialArticles, allArticles) = result + let (technologies, tutorials, tutorialArticles, allArticles, documentationExtensions) = result var (otherArticles, rootPageArticles) = splitArticles(allArticles) - let globalOptions = (allArticles + uncuratedDocumentationExtensions.values.flatMap { $0 }).compactMap { article in + let globalOptions = (allArticles + documentationExtensions).compactMap { article in return article.value.options[.global] } @@ -2236,7 +2299,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { } let rootPages = registerRootPages(from: rootPageArticles, in: bundle) - let (moduleReferences, symbolsURLHierarchy) = try registerSymbols(from: bundle, symbolGraphLoader: symbolGraphLoader) + let (moduleReferences, symbolsURLHierarchy) = try registerSymbols(from: bundle, symbolGraphLoader: symbolGraphLoader, documentationExtensions: documentationExtensions) // We don't need to keep the loader in memory after we've registered all symbols. symbolGraphLoader = nil diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift index 250270b7fd..4ed47f15c4 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift @@ -88,10 +88,12 @@ class DocumentationContext_RootPageTests: XCTestCase { try workspace.registerProvider(dataProvider) // Verify that we emit a warning when trying to make a symbol a root page - XCTAssertTrue(context.problems.contains(where: { problem -> Bool in - return problem.diagnostic.identifier == "org.swift.docc.UnexpectedTechnologyRoot" - && problem.diagnostic.source?.path == "ReleaseNotes/MyClass" - })) + let technologyRootProblem = try XCTUnwrap(context.problems.first(where: { $0.diagnostic.identifier == "org.swift.docc.UnexpectedTechnologyRoot" })) + XCTAssertEqual(technologyRootProblem.diagnostic.source, tempFolderURL.appendingPathComponent("no-sgf-test.docc").appendingPathComponent("MyClass.md")) + XCTAssertEqual(technologyRootProblem.diagnostic.range?.lowerBound.line, 3) + let solution = try XCTUnwrap(technologyRootProblem.possibleSolutions.first) + XCTAssertEqual(solution.replacements.first?.range.lowerBound.line, 3) + XCTAssertEqual(solution.replacements.first?.range.upperBound.line, 3) } } diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift index f8069be6e8..556202e643 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift @@ -2824,6 +2824,119 @@ let expected = """ XCTAssertTrue(tgNode2.contains(articleReference)) } + func testMatchesDocumentationExtensionsAsSymbolLinks() throws { + try XCTSkipUnless(LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver) + + let (_, bundle, context) = try testBundleAndContext(copying: "MixedLanguageFrameworkWithLanguageRefinements") { url in + // Two colliding symbols that differ by capitalization. + try """ + # ``MixedFramework/CollisionsWithDifferentCapitalization/someThing`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + some thing + + This documentation extension link doesn't need disambiguation because "someThing" is capitalized differently than "something". + """.write(to: url.appendingPathComponent("some-thing.md"), atomically: true, encoding: .utf8) + + try """ + # ``MixedFramework/CollisionsWithDifferentCapitalization/something`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + something + + This documentation extension link doesn't need disambiguation because "something" is capitalized differently than "someThing". + """.write(to: url.appendingPathComponent("something.md"), atomically: true, encoding: .utf8) + + // Three colliding symbols that differ by symbol kind. + try """ + # ``MixedFramework/CollisionsWithEscapedKeywords/subscript()-method`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + method + + This documentation extension link can be disambiguated with only the kind information (without the language). + """.write(to: url.appendingPathComponent("method.md"), atomically: true, encoding: .utf8) + + try """ + # ``MixedFramework/CollisionsWithEscapedKeywords/subscript()-subscript`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + subscript + + This documentation extension link can be disambiguated with only the kind information (without the language). + """.write(to: url.appendingPathComponent("subscript.md"), atomically: true, encoding: .utf8) + + try """ + # ``MixedFramework/CollisionsWithEscapedKeywords/subscript()-type.method`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + type method + + This documentation extension link can be disambiguated with only the kind information (without the language). + """.write(to: url.appendingPathComponent("type-method.md"), atomically: true, encoding: .utf8) + } + + do { + // The resolved reference needs more disambiguation than the documentation extension link did. + let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/MixedFramework/CollisionsWithDifferentCapitalization/someThing-90i4h", sourceLanguage: .swift) + + let node = try context.entity(with: reference) + let symbol = try XCTUnwrap(node.semantic as? Symbol) + XCTAssertEqual(symbol.abstract?.plainText, "some thing", "The abstract should be from the overriding documentation extension.") + } + + do { + // The resolved reference needs more disambiguation than the documentation extension link did. + let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/MixedFramework/CollisionsWithDifferentCapitalization/something-2c4k6", sourceLanguage: .swift) + + let node = try context.entity(with: reference) + let symbol = try XCTUnwrap(node.semantic as? Symbol) + XCTAssertEqual(symbol.abstract?.plainText, "something", "The abstract should be from the overriding documentation extension.") + } + + do { + // The resolved reference needs the language info alongside the symbol kind info. + let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/MixedFramework/CollisionsWithEscapedKeywords/subscript()-swift.method", sourceLanguage: .swift) + + let node = try context.entity(with: reference) + let symbol = try XCTUnwrap(node.semantic as? Symbol) + XCTAssertEqual(symbol.abstract?.plainText, "method", "The abstract should be from the overriding documentation extension.") + } + + do { + // The resolved reference needs the language info alongside the symbol kind info. + let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/MixedFramework/CollisionsWithEscapedKeywords/subscript()-swift.subscript", sourceLanguage: .swift) + + let node = try context.entity(with: reference) + let symbol = try XCTUnwrap(node.semantic as? Symbol) + XCTAssertEqual(symbol.abstract?.plainText, "subscript", "The abstract should be from the overriding documentation extension.") + } + + do { + // The resolved reference needs the language info alongside the symbol kind info. + let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/MixedFramework/CollisionsWithEscapedKeywords/subscript()-swift.type.method", sourceLanguage: .swift) + + let node = try context.entity(with: reference) + let symbol = try XCTUnwrap(node.semantic as? Symbol) + XCTAssertEqual(symbol.abstract?.plainText, "type method", "The abstract should be from the overriding documentation extension.") + } + } + func testAutomaticallyCuratesArticles() throws { let articleOne = TextFile(name: "Article1.md", utf8Content: """ # Article 1 From 9bbcf28407af4959663d94f2c80793e320f1ecd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Tue, 25 Apr 2023 13:13:53 -0700 Subject: [PATCH 2/7] Add test for matching language specific documentation extension links --- .../DocumentationContextTests.swift | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift index 556202e643..9fc3a5077b 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift @@ -2937,6 +2937,110 @@ let expected = """ } } + func testMatchesDocumentationExtensionsWithSourceLanguageSpecificLinks() throws { + try XCTSkipUnless(LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver) + + let (_, bundle, context) = try testBundleAndContext(copying: "MixedLanguageFrameworkWithLanguageRefinements") { url in + // typedef NS_OPTIONS(NSInteger, MyObjectiveCOption) { + // MyObjectiveCOptionNone = 0, + // MyObjectiveCOptionFirst = 1 << 0, + // MyObjectiveCOptionSecond NS_SWIFT_NAME(secondCaseSwiftName) = 1 << 1 + // }; + try """ + # ``MixedFramework/MyObjectiveCOption/MyObjectiveCOptionFirst`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + Objective-C option case + + This documentation extension link uses the Objective-C spelling to refer to the "first" option case. + """.write(to: url.appendingPathComponent("objc-case.md"), atomically: true, encoding: .utf8) + + try """ + # ``MixedFramework/MyObjectiveCOption/secondCaseSwiftName`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + Swift spelling of Objective-C option case + + This documentation extension link uses the customized Swift spelling to refer to the "second" option case. + """.write(to: url.appendingPathComponent("objc-case-swift-name.md"), atomically: true, encoding: .utf8) + + // NS_SWIFT_NAME(MyObjectiveCClassSwiftName) + // @interface MyObjectiveCClassObjectiveCName : NSObject + // + // @property (copy, readonly) NSString * myPropertyObjectiveCName NS_SWIFT_NAME(myPropertySwiftName); + // + // - (void)myMethodObjectiveCName NS_SWIFT_NAME(myMethodSwiftName()); + // - (void)myMethodWithArgument:(NSString *)argument NS_SWIFT_NAME(myMethod(argument:)); + // + // @end + try """ + # ``MixedFramework/MyObjectiveCClassObjectiveCName/myMethodWithArgument:`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + Objective-C method with one argument + + This documentation extension link uses the Objective-C spelling to refer to the method with an argument. + """.write(to: url.appendingPathComponent("objc-method.md"), atomically: true, encoding: .utf8) + + try """ + # ``MixedFramework/MyObjectiveCClassSwiftName/myMethodSwiftName()`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + Swift spelling for Objective-C method without arguments + + This documentation extension link uses the customized Swift spelling to refer to the method without an argument. + """.write(to: url.appendingPathComponent("objc-method-swift-name.md"), atomically: true, encoding: .utf8) + } + + do { + // The resolved reference needs more disambiguation than the documentation extension link did. + let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/MixedFramework/MyObjectiveCOption/first", sourceLanguage: .swift) + + let node = try context.entity(with: reference) + let symbol = try XCTUnwrap(node.semantic as? Symbol) + XCTAssertEqual(symbol.abstract?.plainText, "Objective-C option case", "The abstract should be from the overriding documentation extension.") + } + + do { + // The resolved reference needs more disambiguation than the documentation extension link did. + let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/MixedFramework/MyObjectiveCOption/secondCaseSwiftName", sourceLanguage: .swift) + + let node = try context.entity(with: reference) + let symbol = try XCTUnwrap(node.semantic as? Symbol) + XCTAssertEqual(symbol.abstract?.plainText, "Swift spelling of Objective-C option case", "The abstract should be from the overriding documentation extension.") + } + + do { + // The resolved reference needs the language info alongside the symbol kind info. + let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/MixedFramework/MyObjectiveCClassSwiftName/myMethod(argument:)", sourceLanguage: .swift) + + let node = try context.entity(with: reference) + let symbol = try XCTUnwrap(node.semantic as? Symbol) + XCTAssertEqual(symbol.abstract?.plainText, "Objective-C method with one argument", "The abstract should be from the overriding documentation extension.") + } + + do { + // The resolved reference needs the language info alongside the symbol kind info. + let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/MixedFramework/MyObjectiveCClassSwiftName/myMethodSwiftName()", sourceLanguage: .swift) + + let node = try context.entity(with: reference) + let symbol = try XCTUnwrap(node.semantic as? Symbol) + XCTAssertEqual(symbol.abstract?.plainText, "Swift spelling for Objective-C method without arguments", "The abstract should be from the overriding documentation extension.") + } + } + func testAutomaticallyCuratesArticles() throws { let articleOne = TextFile(name: "Article1.md", utf8Content: """ # Article 1 From 97352360835e3d14acccc0ea63c1964a1aa82e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Tue, 25 Apr 2023 13:17:14 -0700 Subject: [PATCH 3/7] Allow documentation extension links to resolve relative to the module rdar://76252171 --- .../Infrastructure/DocumentationContext.swift | 13 +++++- .../PathHierarchyBasedLinkResolver.swift | 5 +++ .../DocumentationContextTests.swift | 45 +++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index ca4d17ee82..b874683e96 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -1337,9 +1337,18 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { } if LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver { - // TODO: Resolve the link relative to the module https://github.com/apple/swift-docc/issues/516 + // If there's a single module then resolve relative to the module symbol. Otherwise resolve relative to the bundle root. + // This means that links can omit the module name if there's only one module but need to start with the module name if there are multiple modules. + let rootReference: ResolvedTopicReference + let moduleReferences = hierarchyBasedLinkResolver!.modules() + if moduleReferences.count == 1 { + rootReference = moduleReferences.first! + } else { + rootReference = bundle.rootReference + } + let reference = TopicReference.unresolved(.init(topicURL: url)) - switch resolve(reference, in: bundle.rootReference, fromSymbolLink: true) { + switch resolve(reference, in: rootReference, fromSymbolLink: true) { case .success(let resolved): uncuratedDocumentationExtensions[resolved, default: []].append(documentationExtension) case .failure(_, _): diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchyBasedLinkResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchyBasedLinkResolver.swift index b1b996a406..440658cad8 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchyBasedLinkResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchyBasedLinkResolver.swift @@ -63,6 +63,11 @@ final class PathHierarchyBasedLinkResolver { return pathHierarchy.topLevelSymbols().map { resolvedReferenceMap[$0]! } } + /// Returns a list of all module symbols. + func modules() -> [ResolvedTopicReference] { + return pathHierarchy.modules.values.map { resolvedReferenceMap[$0.identifier]! } + } + // MARK: - Adding non-symbols /// Map the resolved identifiers to resolved topic references for a given bundle's article, tutorial, and technology root pages. diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift index 9fc3a5077b..bc58d886e7 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift @@ -3041,6 +3041,51 @@ let expected = """ } } + func testMatchesDocumentationExtensionsRelativeToModule() throws { + try XCTSkipUnless(LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver) + + let (_, bundle, context) = try testBundleAndContext(copying: "MixedLanguageFrameworkWithLanguageRefinements") { url in + // Top level symbols, omitting the module name + try """ + # ``MyStruct/myStructProperty`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + my struct property + """.write(to: url.appendingPathComponent("struct-property.md"), atomically: true, encoding: .utf8) + + try """ + # ``MyTypeAlias`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + my type alias + """.write(to: url.appendingPathComponent("alias.md"), atomically: true, encoding: .utf8) + } + + do { + // The resolved reference needs more disambiguation than the documentation extension link did. + let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/MixedFramework/MyStruct/myStructProperty", sourceLanguage: .swift) + + let node = try context.entity(with: reference) + let symbol = try XCTUnwrap(node.semantic as? Symbol) + XCTAssertEqual(symbol.abstract?.plainText, "my struct property", "The abstract should be from the overriding documentation extension.") + } + + do { + // The resolved reference needs more disambiguation than the documentation extension link did. + let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/MixedFramework/MyTypeAlias", sourceLanguage: .swift) + + let node = try context.entity(with: reference) + let symbol = try XCTUnwrap(node.semantic as? Symbol) + XCTAssertEqual(symbol.abstract?.plainText, "my type alias", "The abstract should be from the overriding documentation extension.") + } + } + func testAutomaticallyCuratesArticles() throws { let articleOne = TextFile(name: "Article1.md", utf8Content: """ # Article 1 From 004d46588a2c09a273d69baf6697bea4e8ac8295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Tue, 25 Apr 2023 15:13:08 -0700 Subject: [PATCH 4/7] Warn when a documentation extension doesn't match a symbol rdar://108392639 --- .../Convert/ConvertService.swift | 4 +- .../Infrastructure/DocumentationContext.swift | 107 ++++++++++-------- .../GeneratedDocumentationTopics.swift | 2 +- .../ConvertService/ConvertServiceTests.swift | 15 +-- .../DocumentationContextTests.swift | 92 +++++++++++---- 5 files changed, 132 insertions(+), 88 deletions(-) diff --git a/Sources/SwiftDocC/DocumentationService/Convert/ConvertService.swift b/Sources/SwiftDocC/DocumentationService/Convert/ConvertService.swift index 5b07d7bea4..5fbcac9f76 100644 --- a/Sources/SwiftDocC/DocumentationService/Convert/ConvertService.swift +++ b/Sources/SwiftDocC/DocumentationService/Convert/ConvertService.swift @@ -252,9 +252,7 @@ public struct ConvertService: DocumentationService { baseReferenceStore: RenderReferenceStore? ) -> RenderReferenceStore { let uncuratedArticles = context.uncuratedArticles.map { ($0, isDocumentationExtensionContent: false) } - let uncuratedDocumentationExtensions = context.uncuratedDocumentationExtensions.flatMap { reference, articles in - articles.map { article in ((reference, article), isDocumentationExtensionContent: true) } - } + let uncuratedDocumentationExtensions = context.uncuratedDocumentationExtensions.map { ($0, isDocumentationExtensionContent: true) } let topicContent = (uncuratedArticles + uncuratedDocumentationExtensions) .compactMap { (value, isDocumentationExtensionContent) -> (ResolvedTopicReference, RenderReferenceStore.TopicContent)? in let (topicReference, article) = value diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index b874683e96..5fc126c5e8 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -291,9 +291,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { /// has been built, this list of uncurated documentation extensions will be empty. /// /// The key to lookup a documentation extension file is the symbol reference from its title (level 1 heading). - /// - /// - Warning: It's possible—but not supported—for multiple documentation extension files to specify the same symbol link. - var uncuratedDocumentationExtensions = [ResolvedTopicReference: [SemanticResult
]]() + var uncuratedDocumentationExtensions = [ResolvedTopicReference: SemanticResult
]() /// External metadata injected into the context, for example via command line arguments. public var externalMetadata = ExternalMetadata() @@ -1010,24 +1008,13 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { /// A lookup of resolved references based on the reference's absolute string. private(set) var referenceIndex = [String: ResolvedTopicReference]() - private func nodeWithInitializedContent(reference: ResolvedTopicReference, matches: [DocumentationContext.SemanticResult
]?) -> DocumentationNode { + private func nodeWithInitializedContent(reference: ResolvedTopicReference, match foundDocumentationExtension: DocumentationContext.SemanticResult
?) -> DocumentationNode { precondition(documentationCache.keys.contains(reference)) - // A symbol can have only one documentation extension markdown file, so emit warnings if there are more. - if let matches = matches, matches.count > 1 { - let zeroRange = SourceLocation(line: 1, column: 1, source: nil)..]]() for documentationExtension in documentationExtensions { guard let link = documentationExtension.value.title?.child(at: 0) as? AnyLink else { fatalError("An article shouldn't have ended up in the documentation extension list unless its title was a link. File: \(documentationExtension.source.absoluteString.singleQuoted)") @@ -1350,22 +1339,25 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { let reference = TopicReference.unresolved(.init(topicURL: url)) switch resolve(reference, in: rootReference, fromSymbolLink: true) { case .success(let resolved): - uncuratedDocumentationExtensions[resolved, default: []].append(documentationExtension) - case .failure(_, _): - // TODO: Warn that the link didn't match any symbol https://github.com/apple/swift-docc/issues/560 - - // Fallback to the documentation cache based link resolver's behavior. - let reference = documentationExtension.topicGraphNode.reference - - let symbolPath = NodeURLGenerator.Path.documentation(path: url.components.path).stringValue - let symbolReference = ResolvedTopicReference( - bundleIdentifier: reference.bundleIdentifier, - path: symbolPath, - fragment: nil, - sourceLanguages: reference.sourceLanguages + if let existing = uncuratedDocumentationExtensions[resolved] { + if symbolsWithMultipleDocumentationExtensionMatches[resolved] == nil { + symbolsWithMultipleDocumentationExtensionMatches[resolved] = [existing] + } + symbolsWithMultipleDocumentationExtensionMatches[resolved]!.append(documentationExtension) + } else { + uncuratedDocumentationExtensions[resolved] = documentationExtension + } + case .failure(_, let errorInfo): + // Present a diagnostic specific to documentation extension files but get the solutions and notes from the general unresolved link problem. + let unresolvedLinkProblem = + unresolvedReferenceProblem(reference: reference, source: documentationExtension.source, range: link.range, severity: .warning, uncuratedArticleMatch: nil, errorInfo: errorInfo, fromSymbolLink: link is SymbolLink) + + diagnosticEngine.emit( + Problem( + diagnostic: Diagnostic(source: documentationExtension.source, severity: .warning, range: link.range, identifier: "org.swift.docc.SymbolUnmatched", summary: "No symbol matched \(destination.singleQuoted). \(errorInfo.message).", notes: unresolvedLinkProblem.diagnostic.notes), + possibleSolutions: unresolvedLinkProblem.possibleSolutions + ) ) - - uncuratedDocumentationExtensions[symbolReference, default: []].append(documentationExtension) continue } } else { @@ -1381,9 +1373,18 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { sourceLanguages: reference.sourceLanguages ) - uncuratedDocumentationExtensions[symbolReference, default: []].append(documentationExtension) + if let existing = uncuratedDocumentationExtensions[symbolReference] { + if symbolsWithMultipleDocumentationExtensionMatches[symbolReference] == nil { + symbolsWithMultipleDocumentationExtensionMatches[symbolReference] = [existing] + } + symbolsWithMultipleDocumentationExtensionMatches[symbolReference]!.append(documentationExtension) + } else { + uncuratedDocumentationExtensions[symbolReference] = documentationExtension + } } } + emitWarningsForSymbolsMatchedInMultipleDocumentationExtensions(with: symbolsWithMultipleDocumentationExtensionMatches) + symbolsWithMultipleDocumentationExtensionMatches.removeAll() if hierarchyBasedLinkResolver == nil || LinkResolutionMigrationConfiguration.shouldReportLinkResolutionMismatches { // The `symbolsURLHierarchy` is only used in the cache-based link resolver to traverse the documentation and @@ -1507,12 +1508,12 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { let updatedNodes: [(node: DocumentationNode, matchedArticleURL: URL?)] = Array(symbolIndex.values) .concurrentPerform { finalReference, results in // Match the symbol's documentation extension and initialize the node content. - let matches = uncuratedDocumentationExtensions[finalReference] - let updatedNode = nodeWithInitializedContent(reference: finalReference, matches: matches) + let match = uncuratedDocumentationExtensions[finalReference] + let updatedNode = nodeWithInitializedContent(reference: finalReference, match: match) results.append(( node: updatedNode, - matchedArticleURL: matches?.first?.source + matchedArticleURL: match?.source )) } @@ -1567,6 +1568,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { } } + /// Builds in-memory relationships between symbols based on the relationship information in a given symbol graph file. /// /// - Parameters: @@ -2600,27 +2602,32 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { return crawler.curatedNodes } - /// Emits warnings for unmatched documentation extensions and uncurated articles. - private func emitWarningsForUncuratedTopics() { - // Check that all documentation extension files matched a symbol and that all articles are curated - for results in uncuratedDocumentationExtensions.values { - let articleResult = results.first! - let remaining = results.dropFirst() + /// Emits warnings for symbols that are matched by multiple documentation extensions. + private func emitWarningsForSymbolsMatchedInMultipleDocumentationExtensions(with symbolsWithMultipleDocumentationExtensionMatches: [ResolvedTopicReference : [DocumentationContext.SemanticResult
]]) { + for (reference, documentationExtensions) in symbolsWithMultipleDocumentationExtensionMatches { + let symbolPath = reference.url.pathComponents.dropFirst(2).joined(separator: "/") + let firstExtension = documentationExtensions.first! - guard let link = articleResult.value.title?.child(at: 0) as? AnyLink else { - fatalError("An article shouldn't have ended up in the documentation extension cache unless its title was a link. File: \(articleResult.source.absoluteString.singleQuoted)") + guard let link = firstExtension.value.title?.child(at: 0) as? AnyLink else { + fatalError("An article shouldn't have ended up in the documentation extension list unless its title was a link. File: \(firstExtension.source.absoluteString.singleQuoted)") } - - let notes: [DiagnosticNote] = remaining.map { articleResult in - guard let linkMarkup = articleResult.value.title?.child(at: 0), linkMarkup is AnyLink else { - fatalError("An article shouldn't have ended up in the documentation extension cache unless its title was a link. File: \(articleResult.source.absoluteString.singleQuoted)") + let zeroRange = SourceLocation(line: 1, column: 1, source: nil).. = [unknownSymbolSidecarURL.standardizedFileURL, otherUnknownSymbolSidecarURL.standardizedFileURL] + XCTAssertNotNil(unmatchedSidecarProblem.diagnostic.source) + let sidecarFilesForUnknownSymbol: Set = [unknownSymbolSidecarURL.standardizedFileURL, otherUnknownSymbolSidecarURL.standardizedFileURL] XCTAssertNotNil(unmatchedSidecarProblem) - if let unmatchedSidecarDiagnostic = unmatchedSidecarProblem?.diagnostic { - XCTAssertTrue(sidecarFilesForUnknownSymbol.contains(unmatchedSidecarDiagnostic.source?.standardizedFileURL), "One of the files should be the diagnostic source") - XCTAssertEqual(unmatchedSidecarDiagnostic.range, SourceLocation(line: 1, column: 3, source: unmatchedSidecarProblem?.diagnostic.source).. Date: Tue, 25 Apr 2023 15:55:56 -0700 Subject: [PATCH 5/7] Expect different behavior in tests when the old link resolver is used --- .../Infrastructure/DocumentationContext.swift | 10 +++ .../ConvertService/ConvertServiceTests.swift | 65 +++++++++++++------ .../DocumentationContextTests.swift | 10 ++- 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index 5fc126c5e8..95cfa94afc 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -2628,6 +2628,16 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { /// Emits information diagnostics for uncurated articles. private func emitWarningsForUncuratedTopics() { // Check that all articles are curated + if !LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver { + for articleResult in uncuratedDocumentationExtensions.values { + guard let link = articleResult.value.title?.child(at: 0) as? AnyLink else { + fatalError("An article shouldn't have ended up in the documentation extension cache unless its title was a link. File: \(articleResult.source.absoluteString.singleQuoted)") + } + + diagnosticEngine.emit(Problem(diagnostic: Diagnostic(source: articleResult.source, severity: .information, range: link.range, identifier: "org.swift.docc.SymbolUnmatched", summary: "No symbol matched \(link.destination?.singleQuoted ?? "''"). This documentation will be ignored.", notes: []), possibleSolutions: [])) + } + } + for articleResult in uncuratedArticles.values { diagnosticEngine.emit(Problem(diagnostic: Diagnostic(source: articleResult.source, severity: .information, range: nil, identifier: "org.swift.docc.ArticleUncurated", summary: "You haven't curated \(articleResult.topicGraphNode.reference.description.singleQuoted)"), possibleSolutions: [])) } diff --git a/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift b/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift index 30149448e7..191252cbc4 100644 --- a/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift +++ b/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift @@ -1221,25 +1221,52 @@ class ConvertServiceTests: XCTestCase { assert: { renderNodes, referenceStore in let referenceStore = try XCTUnwrap(referenceStore) - XCTAssertEqual( - Set(referenceStore.topics.keys.map(\.path)), - [ - // This bundle doesn't contain any symbol graphs so documentation extensions can't match with any symbols - - // Articles and tutorials: - "/tutorials/TestOverview", - "/tutorials/TestOverview/$volume", - "/tutorials/TestOverview/Chapter-1", - "/documentation/Test-Bundle/article", - "/documentation/Test-Bundle/article2", - "/documentation/Test-Bundle/article3", - "/tutorials/Test-Bundle/TestTutorial", - "/tutorials/Test-Bundle/TestTutorial2", - "/tutorials/Test-Bundle/TestTutorialArticle", - "/tutorials/Test-Bundle/TutorialMediaWithSpaces", - "/documentation/Test-Bundle/Default-Code-Listing-Syntax", - ] - ) + if LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver { + XCTAssertEqual( + Set(referenceStore.topics.keys.map(\.path)), + [ + // This bundle doesn't contain any symbol graphs so documentation extensions can't match with any symbols + + // Articles and tutorials: + "/tutorials/TestOverview", + "/tutorials/TestOverview/$volume", + "/tutorials/TestOverview/Chapter-1", + "/documentation/Test-Bundle/article", + "/documentation/Test-Bundle/article2", + "/documentation/Test-Bundle/article3", + "/tutorials/Test-Bundle/TestTutorial", + "/tutorials/Test-Bundle/TestTutorial2", + "/tutorials/Test-Bundle/TestTutorialArticle", + "/tutorials/Test-Bundle/TutorialMediaWithSpaces", + "/documentation/Test-Bundle/Default-Code-Listing-Syntax", + ] + ) + } else { + XCTAssertEqual( + Set(referenceStore.topics.keys.map(\.path)), + [ + // Documentation extension files: + "/documentation/MyKit", + "/documentation/SideKit", + "/documentation/MyKit/MyClass", + "/documentation/MyKit/MyProtocol", + "/documentation/SideKit/SideClass/init()", + + // Articles and tutorials: + "/tutorials/TestOverview", + "/tutorials/TestOverview/$volume", + "/tutorials/TestOverview/Chapter-1", + "/documentation/Test-Bundle/article", + "/documentation/Test-Bundle/article2", + "/documentation/Test-Bundle/article3", + "/tutorials/Test-Bundle/TestTutorial", + "/tutorials/Test-Bundle/TestTutorial2", + "/tutorials/Test-Bundle/TestTutorialArticle", + "/tutorials/Test-Bundle/TutorialMediaWithSpaces", + "/documentation/Test-Bundle/Default-Code-Listing-Syntax", + ] + ) + } try self.assertReferenceStoreContains( referenceStore: referenceStore, diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift index a82dd94f0c..d1ce125a24 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift @@ -1916,8 +1916,14 @@ let expected = """ let unmatchedSidecarDiagnostic = unmatchedSidecarProblem.diagnostic XCTAssertTrue(sidecarFilesForUnknownSymbol.contains(unmatchedSidecarDiagnostic.source?.standardizedFileURL), "One of the files should be the diagnostic source") XCTAssertEqual(unmatchedSidecarDiagnostic.range, SourceLocation(line: 1, column: 3, source: unmatchedSidecarProblem.diagnostic.source).. Date: Wed, 26 Apr 2023 11:28:28 -0700 Subject: [PATCH 6/7] Restore old implementation detail for ConvertService only --- .../Convert/ConvertService.swift | 1 + .../Infrastructure/DocumentationContext.swift | 44 ++++++++++- .../ConvertService/ConvertServiceTests.swift | 73 +++++++------------ 3 files changed, 70 insertions(+), 48 deletions(-) diff --git a/Sources/SwiftDocC/DocumentationService/Convert/ConvertService.swift b/Sources/SwiftDocC/DocumentationService/Convert/ConvertService.swift index 5fbcac9f76..90515a95e5 100644 --- a/Sources/SwiftDocC/DocumentationService/Convert/ConvertService.swift +++ b/Sources/SwiftDocC/DocumentationService/Convert/ConvertService.swift @@ -149,6 +149,7 @@ public struct ConvertService: DocumentationService { // Enable support for generating documentation for standalone articles and tutorials. context.allowsRegisteringArticlesWithoutTechnologyRoot = true context.allowsRegisteringUncuratedTutorials = true + context.considerDocumentationExtensionsThatDoNotMatchSymbolsAsResolved = true context.configureSymbolGraph = { symbolGraph in for (symbolIdentifier, overridingDocumentationComment) in request.overridingDocumentationComments ?? [:] { diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index 95cfa94afc..a854b741c2 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -149,9 +149,18 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { /// Controls whether tutorials that aren't curated in a tutorials overview page are registered and translated. /// /// Set this property to `true` to enable registering documentation for standalone tutorials, - /// for example when ``ConvertService``. + /// for example when using ``ConvertService``. var allowsRegisteringUncuratedTutorials: Bool = false + /// Controls whether documentation extension files are considered resolved even when they don't match a symbol. + /// + /// Set this property to `true` to always consider documentation extensions as "resolved", for example when using ``ConvertService``. + /// + /// > Note: + /// > Setting this property tor `true` means taking over the responsibility to match documentation extension files to symbols + /// > diagnosing unmatched documentation extension files, and diagnostic symbols that match multiple documentation extension files. + var considerDocumentationExtensionsThatDoNotMatchSymbolsAsResolved: Bool = false + /// A closure that modifies each symbol graph that the context registers. /// /// Set this property if you need to modify symbol graphs before the context registers its information. @@ -1348,6 +1357,38 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { uncuratedDocumentationExtensions[resolved] = documentationExtension } case .failure(_, let errorInfo): + guard !considerDocumentationExtensionsThatDoNotMatchSymbolsAsResolved else { + // The ConvertService relies on old implementation detail where documentation extension files were always considered "resolved" even when they didn't match a symbol. + // + // Don't rely on this behavior for new functionality. The behavior will be removed once we have a new solution to meets the needs of the ConvertService. (rdar://108563483) + // https://github.com/apple/swift-docc/issues/567 + // + // The process that interacts with the convert service is responsible for: + // - Distinguishing between documentation extension files that match symbols and documentation extension files that don't match symbols. + // - Resolving symbol link in a way that match the behavior of regular documentation builds. + // the process that interacts with the convert service is responsible for maintaining it's own link resolutions implementation to match the behavior of a regular build. + // - Diagnosing documentation extension files that don't match any symbols. + let reference = documentationExtension.topicGraphNode.reference + + let symbolPath = NodeURLGenerator.Path.documentation(path: url.components.path).stringValue + let symbolReference = ResolvedTopicReference( + bundleIdentifier: reference.bundleIdentifier, + path: symbolPath, + fragment: nil, + sourceLanguages: reference.sourceLanguages + ) + + if let existing = uncuratedDocumentationExtensions[symbolReference] { + if symbolsWithMultipleDocumentationExtensionMatches[symbolReference] == nil { + symbolsWithMultipleDocumentationExtensionMatches[symbolReference] = [existing] + } + symbolsWithMultipleDocumentationExtensionMatches[symbolReference]!.append(documentationExtension) + } else { + uncuratedDocumentationExtensions[symbolReference] = documentationExtension + } + continue + } + // Present a diagnostic specific to documentation extension files but get the solutions and notes from the general unresolved link problem. let unresolvedLinkProblem = unresolvedReferenceProblem(reference: reference, source: documentationExtension.source, range: link.range, severity: .warning, uncuratedArticleMatch: nil, errorInfo: errorInfo, fromSymbolLink: link is SymbolLink) @@ -1358,7 +1399,6 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { possibleSolutions: unresolvedLinkProblem.possibleSolutions ) ) - continue } } else { // The documentation cache based link resolver doesn't "resolve" the links in the documentation extension titles. diff --git a/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift b/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift index 191252cbc4..1f94632cfd 100644 --- a/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift +++ b/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift @@ -1221,52 +1221,33 @@ class ConvertServiceTests: XCTestCase { assert: { renderNodes, referenceStore in let referenceStore = try XCTUnwrap(referenceStore) - if LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver { - XCTAssertEqual( - Set(referenceStore.topics.keys.map(\.path)), - [ - // This bundle doesn't contain any symbol graphs so documentation extensions can't match with any symbols - - // Articles and tutorials: - "/tutorials/TestOverview", - "/tutorials/TestOverview/$volume", - "/tutorials/TestOverview/Chapter-1", - "/documentation/Test-Bundle/article", - "/documentation/Test-Bundle/article2", - "/documentation/Test-Bundle/article3", - "/tutorials/Test-Bundle/TestTutorial", - "/tutorials/Test-Bundle/TestTutorial2", - "/tutorials/Test-Bundle/TestTutorialArticle", - "/tutorials/Test-Bundle/TutorialMediaWithSpaces", - "/documentation/Test-Bundle/Default-Code-Listing-Syntax", - ] - ) - } else { - XCTAssertEqual( - Set(referenceStore.topics.keys.map(\.path)), - [ - // Documentation extension files: - "/documentation/MyKit", - "/documentation/SideKit", - "/documentation/MyKit/MyClass", - "/documentation/MyKit/MyProtocol", - "/documentation/SideKit/SideClass/init()", - - // Articles and tutorials: - "/tutorials/TestOverview", - "/tutorials/TestOverview/$volume", - "/tutorials/TestOverview/Chapter-1", - "/documentation/Test-Bundle/article", - "/documentation/Test-Bundle/article2", - "/documentation/Test-Bundle/article3", - "/tutorials/Test-Bundle/TestTutorial", - "/tutorials/Test-Bundle/TestTutorial2", - "/tutorials/Test-Bundle/TestTutorialArticle", - "/tutorials/Test-Bundle/TutorialMediaWithSpaces", - "/documentation/Test-Bundle/Default-Code-Listing-Syntax", - ] - ) - } + // The ConvertService relies on old implementation detail where documentation extension files were always considered "resolved" even when they didn't match a symbol. (rdar://108563483) + // https://github.com/apple/swift-docc/issues/567 + + XCTAssertEqual( + Set(referenceStore.topics.keys.map(\.path)), + [ + // Documentation extension files: + "/documentation/MyKit", + "/documentation/SideKit", + "/documentation/MyKit/MyClass", + "/documentation/MyKit/MyProtocol", + "/documentation/SideKit/SideClass/init()", + + // Articles and tutorials: + "/tutorials/TestOverview", + "/tutorials/TestOverview/$volume", + "/tutorials/TestOverview/Chapter-1", + "/documentation/Test-Bundle/article", + "/documentation/Test-Bundle/article2", + "/documentation/Test-Bundle/article3", + "/tutorials/Test-Bundle/TestTutorial", + "/tutorials/Test-Bundle/TestTutorial2", + "/tutorials/Test-Bundle/TestTutorialArticle", + "/tutorials/Test-Bundle/TutorialMediaWithSpaces", + "/documentation/Test-Bundle/Default-Code-Listing-Syntax", + ] + ) try self.assertReferenceStoreContains( referenceStore: referenceStore, From f7363f4a7edc17c259a21ee55a977e6b5e70ceeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Thu, 27 Apr 2023 08:12:58 -0700 Subject: [PATCH 7/7] Restore test assertion about documentation extension in asset store --- .../ConvertService/ConvertServiceTests.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift b/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift index 1f94632cfd..5d198552fb 100644 --- a/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift +++ b/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift @@ -1248,6 +1248,13 @@ class ConvertServiceTests: XCTestCase { "/documentation/Test-Bundle/Default-Code-Listing-Syntax", ] ) + try self.assertReferenceStoreContains( + referenceStore: referenceStore, + topicPath: "/documentation/MyKit/MyClass", + source: testBundleURL.appendingPathComponent("documentation/myclass.md"), + title: "doc:MyKit/MyClass", + isDocumentationExtensionContent: true + ) try self.assertReferenceStoreContains( referenceStore: referenceStore,