diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index 02784d2021..724492fb45 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -138,14 +138,13 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { } /// The root module nodes of the Topic Graph. - public var rootModules: [ResolvedTopicReference] { - return topicGraph.nodes.values.compactMap { node in - guard node.kind == .module, - !onlyHasSnippetRelatedChildren(for: node.reference) else { - return nil - } - return node.reference - } + /// + /// This property is initialized during the registration of a documentation bundle. + public private(set) var rootModules: [ResolvedTopicReference]! + + /// The topic reference of the root module, if it's the only registered module. + var soleRootModuleReference: ResolvedTopicReference? { + rootModules.count == 1 ? rootModules.first : nil } /// Map of document URLs to topic references. @@ -1624,9 +1623,23 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { /// - Parameters: /// - articles: Articles to register with the documentation cache. /// - bundle: The bundle containing the articles. - private func registerArticles(_ articles: DocumentationContext.Articles, in bundle: DocumentationBundle) { - for article in articles { - guard let (documentation, title) = DocumentationContext.documentationNodeAndTitle(for: article, kind: .article, in: bundle) else { continue } + /// - Returns: The articles that were registered, with their topic graph node updated to what's been added to the topic graph. + private func registerArticles( + _ articles: DocumentationContext.Articles, + in bundle: DocumentationBundle + ) -> DocumentationContext.Articles { + articles.map { article in + guard let (documentation, title) = DocumentationContext.documentationNodeAndTitle( + for: article, + + // Articles are available in the same languages the only root module is available in. If there is more + // than one module, we cannot determine what languages it's available in and default to Swift. + availableSourceLanguages: soleRootModuleReference?.sourceLanguages, + kind: .article, + in: bundle + ) else { + return article + } let reference = documentation.reference documentationCache[reference] = documentation @@ -1638,6 +1651,11 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { for anchor in documentation.anchorSections { nodeAnchorSections[anchor.reference] = anchor } + + var article = article + // Update the article's topic graph node with the one we just added to the topic graph. + article.topicGraphNode = graphNode + return article } } @@ -1648,16 +1666,45 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { /// - kind: The kind that should be used to create the returned documentation node. /// - bundle: The documentation bundle this article belongs to. /// - Returns: A documentation node and title for the given article semantic result. - static func documentationNodeAndTitle(for article: DocumentationContext.SemanticResult
, kind: DocumentationNode.Kind, in bundle: DocumentationBundle) -> (node: DocumentationNode, title: String)? { + static func documentationNodeAndTitle( + for article: DocumentationContext.SemanticResult
, + availableSourceLanguages: Set? = nil, + kind: DocumentationNode.Kind, + in bundle: DocumentationBundle + ) -> (node: DocumentationNode, title: String)? { guard let articleMarkup = article.value.markup else { return nil } let path = NodeURLGenerator.pathForSemantic(article.value, source: article.source, bundle: bundle) - let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: path, sourceLanguage: .swift) + + // If available source languages are provided and it contains Swift, use Swift as the default language of + // the article. + let defaultSourceLanguage = availableSourceLanguages.map { availableSourceLanguages in + if availableSourceLanguages.contains(.swift) { + return .swift + } else { + return availableSourceLanguages.first ?? .swift + } + } ?? SourceLanguage.swift + + let reference = ResolvedTopicReference( + bundleIdentifier: bundle.identifier, + path: path, + sourceLanguages: availableSourceLanguages ?? [.swift] + ) + let title = article.topicGraphNode.title - let documentationNode = DocumentationNode(reference: reference, kind: kind, sourceLanguage: .swift, name: .conceptual(title: title), markup: articleMarkup, semantic: article.value) + let documentationNode = DocumentationNode( + reference: reference, + kind: kind, + sourceLanguage: defaultSourceLanguage, + availableSourceLanguages: availableSourceLanguages, + name: .conceptual(title: title), + markup: articleMarkup, + semantic: article.value + ) return (documentationNode, title) } @@ -1693,11 +1740,29 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { } let articleReferences = autoCuratedArticles.map(\.topicGraphNode.reference) - let autoArticlesSection = AutomaticTaskGroupSection(title: "Articles", references: articleReferences, renderPositionPreference: .top) + + func createAutomaticTaskGroupSection(references: [ResolvedTopicReference]) -> AutomaticTaskGroupSection { + AutomaticTaskGroupSection( + title: "Articles", + references: references, + renderPositionPreference: .top + ) + } let node = try entity(with: rootNode.reference) - if var taskGroupProviding = node.semantic as? AutomaticTaskGroupsProviding { - taskGroupProviding.automaticTaskGroups = [autoArticlesSection] + + // If the node we're automatically curating the article under is a symbol, automatically curate the article + // for each language it's available in. + if let symbol = node.semantic as? Symbol { + for sourceLanguage in node.availableSourceLanguages { + symbol.automaticTaskGroupsVariants[ + .init(interfaceLanguage: sourceLanguage.id) + ] = [createAutomaticTaskGroupSection(references: articleReferences)] + } + } else if var taskGroupProviding = node.semantic as? AutomaticTaskGroupsProviding { + taskGroupProviding.automaticTaskGroups = [ + createAutomaticTaskGroupSection(references: articleReferences) + ] } return articleReferences @@ -1779,7 +1844,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { // All discovery went well, process the inputs. let (technologies, tutorials, tutorialArticles, allArticles) = result - let (otherArticles, rootPageArticles) = splitArticles(allArticles) + var (otherArticles, rootPageArticles) = splitArticles(allArticles) let rootPages = registerRootPages(from: rootPageArticles, in: bundle) let (moduleReferences, symbolsURLHierarchy) = try registerSymbols(from: bundle, symbolGraphLoader: symbolGraphLoader) @@ -1788,10 +1853,20 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { try shouldContinueRegistration() + // Keep track of the root modules registered from symbol graph files, we'll need them to automatically + // curate articles. + rootModules = topicGraph.nodes.values.compactMap { node in + guard node.kind == .module, + !onlyHasSnippetRelatedChildren(for: node.reference) else { + return nil + } + return node.reference + } + // Articles that will be automatically curated can be resolved but they need to be pre registered before resolving links. - let rootNodeForAutomaticCuration = rootModules.count == 1 ? rootModules.first.flatMap(topicGraph.nodeWithReference) : nil + let rootNodeForAutomaticCuration = soleRootModuleReference.flatMap(topicGraph.nodeWithReference(_:)) if rootNodeForAutomaticCuration != nil { - registerArticles(otherArticles, in: bundle) + otherArticles = registerArticles(otherArticles, in: bundle) try shouldContinueRegistration() } @@ -2550,32 +2625,6 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { return results } - /// Finds the presentation language the symbol is curated under by walking - /// the documentation graph upwards and finding a parent that has an interface language. - /// Will return `nil` if no parent with assigned interface language is found - /// (e.g. in a tutorials only bundle). - func interfaceLanguageFor(_ reference: ResolvedTopicReference) throws -> SourceLanguage? { - let node = try entity(with: reference) - guard !(node.semantic is Symbol) else { - // For symbols just return the source language - return node.sourceLanguage - } - - // `pathsTo()` returns the canonical path first - guard let canonical = pathsTo(reference).first else { - // Uncurated symbol, if not expected a warning will be emitted elsewhere - return nil - } - - // Return the language of the first symbol entity - return canonical.mapFirst { reference -> SourceLanguage? in - guard let node = try? entity(with: reference), node.semantic is Symbol else { - return nil - } - return node.sourceLanguage - } - } - func dumpGraph() -> String { return topicGraph.nodes.values .filter { parents(of: $0.reference).isEmpty } diff --git a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift index 3d246282b3..e8150bdcc5 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift @@ -586,16 +586,7 @@ public struct RenderNodeTranslator: SemanticVisitor { collectedTopicReferences.append(contentsOf: hierarchyTranslator.collectedTopicReferences) node.hierarchy = hierarchy - // Find the language of the symbol that curated the article in the graph - // and use it as the interface language for that article. - if let language = try! context.interfaceLanguageFor(identifier)?.id { - let generator = PresentationURLGenerator(context: context, baseURL: bundle.baseURL) - node.variants = [ - .init(traits: [.interfaceLanguage(language)], paths: [ - generator.presentationURLForReference(identifier).path - ]) - ] - } + node.variants = variants(for: documentationNode) if let abstract = article.abstractSection, let abstractContent = visitMarkup(abstract.content) as? [RenderInlineContent] { @@ -960,25 +951,7 @@ public struct RenderNodeTranslator: SemanticVisitor { node.metadata.fragmentsVariants = contentRenderer.subHeadingFragments(for: documentationNode) node.metadata.navigatorTitleVariants = contentRenderer.navigatorFragments(for: documentationNode) - let generator = PresentationURLGenerator(context: context, baseURL: bundle.baseURL) - - node.variants = documentationNode.availableSourceLanguages - .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 { sourceLanguage in - RenderNode.Variant( - traits: [.interfaceLanguage(sourceLanguage.id)], - paths: [ - generator.presentationURLForReference(identifier).path - ] - ) - } + node.variants = variants(for: documentationNode) collectedTopicReferences.append(identifier) @@ -1406,6 +1379,28 @@ 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 + // Emit Swift first, then alphabetically. + switch (language1, language2) { + 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 + ] + ) + } + } + init( context: DocumentationContext, bundle: DocumentationBundle, diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContextTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContextTests.swift index 8ae411f8c5..f961d7f788 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContextTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContextTests.swift @@ -1339,17 +1339,6 @@ let expected = """ let canonicalPathFFF = try XCTUnwrap(context.pathsTo(fffNode.reference).first) XCTAssertEqual(["/documentation/MyKit"], canonicalPathFFF.map({ $0.path })) } - - func testLanguageForNode() throws { - let workspace = DocumentationWorkspace() - let context = try DocumentationContext(dataProvider: workspace) - let bundle = try testBundle(named: "TestBundle") - let dataProvider = PrebuiltLocalFileSystemDataProvider(bundles: [bundle]) - try workspace.registerProvider(dataProvider) - let articleReference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/Test-Bundle/article", sourceLanguage: .swift) - let articleLanguage = try context.interfaceLanguageFor(articleReference) - XCTAssertEqual(articleLanguage, SourceLanguage.swift) - } // Verify that a symbol that has no parents in the symbol graph is automatically curated under the module node. func testRootSymbolsAreCureatedInModule() throws { @@ -3029,6 +3018,91 @@ let expected = """ "Expected the symbol instances in the documentationCache and symbolIndex dictionaries to be the same" ) } + + func assertArticleAvailableSourceLanguages( + moduleAvailableLanguages: Set, + expectedArticleDefaultLanguage: SourceLanguage, + file: StaticString = #file, + line: UInt = #line + ) throws { + precondition( + moduleAvailableLanguages.allSatisfy { [.swift, .objectiveC].contains($0) }, + "moduleAvailableLanguages can only contain Swift and Objective-C as languages." + ) + + let (_, _, context) = try testBundleAndContext(copying: "MixedLanguageFramework") { url in + try """ + # MyArticle + + The framework this article is documenting is available in the following languages: \ + \(moduleAvailableLanguages.map(\.name).joined(separator: ",")). + """.write(to: url.appendingPathComponent("myarticle.md"), atomically: true, encoding: .utf8) + + func removeSymbolGraph(compiler: String) throws { + try FileManager.default.removeItem( + at: url.appendingPathComponent("symbol-graphs").appendingPathComponent(compiler) + ) + } + + if !moduleAvailableLanguages.contains(.swift) { + try removeSymbolGraph(compiler: "swift") + } + + if !moduleAvailableLanguages.contains(.objectiveC) { + try removeSymbolGraph(compiler: "clang") + } + } + + let articleNode = try XCTUnwrap( + context.documentationCache.first { + $0.key.path == "/documentation/MixedLanguageFramework/myarticle" + }?.value, + file: file, + line: line + ) + + XCTAssertEqual( + articleNode.availableSourceLanguages, + moduleAvailableLanguages, + "Expected the article's source languages to have inherited from the module's available source languages.", + file: file, + line: line + ) + + XCTAssertEqual( + articleNode.sourceLanguage, + expectedArticleDefaultLanguage, + file: file, + line: line + ) + } + + func testArticleAvailableSourceLanguagesIsSwiftInSwiftModule() throws { + enableFeatureFlag(\.isExperimentalObjectiveCSupportEnabled) + + try assertArticleAvailableSourceLanguages( + moduleAvailableLanguages: [.swift], + expectedArticleDefaultLanguage: .swift + ) + } + + func testArticleAvailableSourceLanguagesIsMixedLanguageInMixedLanguageModule() throws { + enableFeatureFlag(\.isExperimentalObjectiveCSupportEnabled) + + try assertArticleAvailableSourceLanguages( + moduleAvailableLanguages: [.swift, .objectiveC], + expectedArticleDefaultLanguage: .swift + ) + } + + func testArticleAvailableSourceLanguagesIsObjectiveCInObjectiveCModule() throws { + enableFeatureFlag(\.isExperimentalObjectiveCSupportEnabled) + + try assertArticleAvailableSourceLanguages( + moduleAvailableLanguages: [.objectiveC], + expectedArticleDefaultLanguage: .objectiveC + ) + } } func assertEqualDumps(_ lhs: String, _ rhs: String, file: StaticString = #file, line: UInt = #line) { diff --git a/Tests/SwiftDocCTests/Model/SemaToRenderNodeMultiLanguageTests.swift b/Tests/SwiftDocCTests/Model/SemaToRenderNodeMultiLanguageTests.swift index 1b0e91c9d9..ea4b7a14ce 100644 --- a/Tests/SwiftDocCTests/Model/SemaToRenderNodeMultiLanguageTests.swift +++ b/Tests/SwiftDocCTests/Model/SemaToRenderNodeMultiLanguageTests.swift @@ -156,14 +156,8 @@ class SemaToRenderNodeMixedLanguageTests: ExperimentalObjectiveCTestCase { } ) - let objectiveCVariantData = try RenderNodeVariantOverridesApplier().applyVariantOverrides( - in: RenderJSONEncoder.makeEncoder().encode(mixedLanguageFrameworkRenderNode), - for: [.interfaceLanguage("occ")] - ) - - let objectiveCVariantNode = try RenderJSONDecoder.makeDecoder().decode( - RenderNode.self, - from: objectiveCVariantData + let objectiveCVariantNode = try renderNodeApplyingObjectiveCVariantOverrides( + to: mixedLanguageFrameworkRenderNode ) assertExpectedContent( @@ -248,15 +242,7 @@ class SemaToRenderNodeMixedLanguageTests: ExperimentalObjectiveCTestCase { } ) - let objectiveCVariantData = try RenderNodeVariantOverridesApplier().applyVariantOverrides( - in: RenderJSONEncoder.makeEncoder().encode(fooRenderNode), - for: [.interfaceLanguage("occ")] - ) - - let objectiveCVariantNode = try RenderJSONDecoder.makeDecoder().decode( - RenderNode.self, - from: objectiveCVariantData - ) + let objectiveCVariantNode = try renderNodeApplyingObjectiveCVariantOverrides(to: fooRenderNode) assertExpectedContent( objectiveCVariantNode, @@ -364,10 +350,75 @@ class SemaToRenderNodeMixedLanguageTests: ExperimentalObjectiveCTestCase { ], "Both spellings of the symbol link should resolve to the canonical reference.") } + func testArticleInMixedLanguageFramework() throws { + enableFeatureFlag(\.isExperimentalObjectiveCSupportEnabled) + + let outputConsumer = try mixedLanguageFrameworkConsumer() { url in + try """ + # MyArticle + + An article in a mixed-language framework. This symbol link should display the correct title depending on \ + the language we're browsing this article in: ``MixedLanguageFramework/Bar/myStringFunction(_:)``. + """.write(to: url.appendingPathComponent("bar.md"), atomically: true, encoding: .utf8) + } + + let articleRenderNode = try outputConsumer.renderNode(withTitle: "MyArticle") + + assertExpectedContent( + articleRenderNode, + sourceLanguage: "swift", + title: "MyArticle", + navigatorTitle: nil, + abstract: """ + An article in a mixed-language framework. This symbol link should display the correct title depending on \ + the language we’re browsing this article in: . + """, + declarationTokens: nil, + discussionSection: nil, + topicSectionIdentifiers: [], + referenceTitles: [ + "MixedLanguageFramework", + "myStringFunction(_:)", + ], + referenceFragments: [ + "class func myStringFunction(String) throws -> String", + ], + failureMessage: { fieldName in + "Swift variant of 'MyArticle' article has unexpected content for '\(fieldName)'." + } + ) + + let objectiveCVariantNode = try renderNodeApplyingObjectiveCVariantOverrides(to: articleRenderNode) + + assertExpectedContent( + objectiveCVariantNode, + sourceLanguage: "occ", + title: "MyArticle", + navigatorTitle: nil, + abstract: """ + An article in a mixed-language framework. This symbol link should display the correct title depending on \ + the language we’re browsing this article in: . + """, + declarationTokens: nil, + discussionSection: nil, + topicSectionIdentifiers: [], + referenceTitles: [ + "MixedLanguageFramework", + "myStringFunction:error:", + ], + referenceFragments: [ + "typedef enum Foo : NSString {\n ...\n} Foo;", + ], + failureMessage: { fieldName in + "Swift variant of 'MyArticle' article has unexpected content for '\(fieldName)'." + } + ) + } + func assertExpectedContent( _ renderNode: RenderNode, sourceLanguage expectedSourceLanguage: String, - symbolKind expectedSymbolKind: String, + symbolKind expectedSymbolKind: String? = nil, title expectedTitle: String, navigatorTitle expectedNavigatorTitle: String?, abstract expectedAbstract: String, @@ -467,6 +518,18 @@ class SemaToRenderNodeMixedLanguageTests: ExperimentalObjectiveCTestCase { line: line ) } + + func renderNodeApplyingObjectiveCVariantOverrides(to renderNode: RenderNode) throws -> RenderNode { + let objectiveCVariantData = try RenderNodeVariantOverridesApplier().applyVariantOverrides( + in: RenderJSONEncoder.makeEncoder().encode(renderNode), + for: [.interfaceLanguage("occ")] + ) + + return try RenderJSONDecoder.makeDecoder().decode( + RenderNode.self, + from: objectiveCVariantData + ) + } } private class TestRenderNodeOutputConsumer: ConvertOutputConsumer { @@ -508,9 +571,17 @@ extension TestRenderNodeOutputConsumer { } func renderNode(withIdentifier identifier: String) throws -> RenderNode { + try renderNode(where: { renderNode in renderNode.metadata.externalID == identifier }) + } + + func renderNode(withTitle title: String) throws -> RenderNode { + try renderNode(where: { renderNode in renderNode.metadata.title == title }) + } + + private func renderNode(where predicate: (RenderNode) -> Bool) throws -> RenderNode { let renderNode = renderNodes.sync { renderNodes in renderNodes.first { renderNode in - renderNode.metadata.externalID == identifier + predicate(renderNode) } } @@ -519,8 +590,13 @@ extension TestRenderNodeOutputConsumer { } fileprivate extension SemaToRenderNodeMixedLanguageTests { - func mixedLanguageFrameworkConsumer() throws -> TestRenderNodeOutputConsumer { - let (bundleURL, _, context) = try testBundleAndContext(copying: "MixedLanguageFramework") + func mixedLanguageFrameworkConsumer( + configureBundle: ((URL) throws -> Void)? = nil + ) throws -> TestRenderNodeOutputConsumer { + let (bundleURL, _, context) = try testBundleAndContext( + copying: "MixedLanguageFramework", + configureBundle: configureBundle + ) var converter = DocumentationConverter( documentationBundleURL: bundleURL,