diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index 83a9aabba..83a94dc89 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -1317,6 +1317,22 @@ public class DocumentationContext { uniqueRelationships.formUnion(unifiedSymbolGraph.orphanRelationships) } + // Warn when the documentation contains more than one main module + if moduleReferences.count > 1 { + let diagnostic = Diagnostic( + source: nil, + severity: .warning, + range: nil, + identifier: "org.swift.docc.MultipleMainModules", + summary: "Documentation contains more than one main module", + explanation: """ + The documentation inputs contain symbol graphs for more than one main module: \(moduleReferences.keys.sorted().joined(separator: ", ")). + This may lead to unexpected behaviors in the generated documentation. + """ + ) + diagnosticEngine.emit(Problem(diagnostic: diagnostic)) + } + try shouldContinueRegistration() // Only add the symbol mapping now if the path hierarchy based resolver is the main implementation. @@ -2309,6 +2325,38 @@ public class DocumentationContext { let (tutorialTableOfContentsResults, tutorials, tutorialArticles, allArticles, documentationExtensions) = result var (otherArticles, rootPageArticles) = splitArticles(allArticles) + // Warn when the documentation contains more than one root page + if rootPageArticles.count > 1 { + let extraRootPageProblems = rootPageArticles.map { rootPageArticle -> Problem in + let diagnostic = Diagnostic( + source: rootPageArticle.source, + severity: .warning, + range: rootPageArticle.value.metadata?.technologyRoot?.originalMarkup.range, + identifier: "org.swift.docc.MultipleTechnologyRoots", + summary: "Documentation contains more than one root page", + explanation: """ + The documentation contains \(rootPageArticles.count) articles with \(TechnologyRoot.directiveName.singleQuoted) directives. + Only one article should be marked as a technology root to avoid unexpected behaviors. + """ + ) + + guard let range = rootPageArticle.value.metadata?.technologyRoot?.originalMarkup.range else { + return Problem(diagnostic: diagnostic) + } + + let solution = Solution( + summary: "Remove the \(TechnologyRoot.directiveName.singleQuoted) directive", + replacements: [ + Replacement(range: range, replacement: "") + ] + ) + + return Problem(diagnostic: diagnostic, possibleSolutions: [solution]) + } + + diagnosticEngine.emit(extraRootPageProblems) + } + let globalOptions = (allArticles + documentationExtensions).compactMap { article in return article.value.options[.global] } diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift index 9523dc096..5b73d1b1c 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift @@ -169,4 +169,175 @@ class DocumentationContext_RootPageTests: XCTestCase { XCTAssertEqual(context.problems.count, 0) } + + func testWarnsAboutMultipleTechnologyRootDirectives() async throws { + let (_, context) = try await loadBundle(catalog: + Folder(name: "multiple-roots.docc", content: [ + TextFile(name: "FirstRoot.md", utf8Content: """ + # First Root + @Metadata { + @TechnologyRoot + } + This is the first root page. + """), + + TextFile(name: "SecondRoot.md", utf8Content: """ + # Second Root + @Metadata { + @TechnologyRoot + } + This is the second root page. + """), + + TextFile(name: "ThirdRoot.md", utf8Content: """ + # Third Root + @Metadata { + @TechnologyRoot + } + This is the third root page. + """), + + InfoPlist(displayName: "TestBundle", identifier: "com.test.example"), + ]) + ) + + // Verify that we emit warnings for multiple TechnologyRoot directives + let multipleRootsProblems = context.problems.filter { $0.diagnostic.identifier == "org.swift.docc.MultipleTechnologyRoots" } + XCTAssertEqual(multipleRootsProblems.count, 3, "Should emit warnings for all three TechnologyRoot directives") + + // Verify the warnings are associated with the correct files + let problemSources = multipleRootsProblems.compactMap { $0.diagnostic.source?.lastPathComponent }.sorted() + XCTAssertEqual(problemSources, ["FirstRoot.md", "SecondRoot.md", "ThirdRoot.md"]) + + // Verify each warning has a solution to remove the TechnologyRoot directive + for problem in multipleRootsProblems { + XCTAssertEqual(problem.possibleSolutions.count, 1) + let solution = problem.possibleSolutions.first! + XCTAssertEqual(solution.summary, "Remove the 'TechnologyRoot' directive") + XCTAssertEqual(solution.replacements.count, 1) + } + } + + func testWarnsAboutMultipleMainModules() async throws { + // Create a bundle with multiple symbol graphs for different modules + let (_, context) = try await loadBundle(catalog: + Folder(name: "multiple-modules.docc", content: [ + // First module symbol graph + TextFile(name: "ModuleA.symbols.json", utf8Content: """ + { + "metadata": { + "formatVersion": { + "major": 1, + "minor": 0, + "patch": 0 + }, + "generator": "unit-test" + }, + "module": { + "name": "ModuleA", + "platform": { + "architecture": "x86_64", + "vendor": "apple", + "operatingSystem": { + "name": "macosx", + "minimumVersion": { + "major": 10, + "minor": 15 + } + }, + "environment": null + } + }, + "symbols": [ + { + "identifier": { + "precise": "ModuleA", + "interfaceLanguage": "swift" + }, + "names": { + "title": "ModuleA", + "navigator": null, + "subHeading": null, + "prose": null + }, + "pathComponents": ["ModuleA"], + "docComment": null, + "accessLevel": "public", + "kind": { + "identifier": "module", + "displayName": "Module" + }, + "mixins": {} + } + ], + "relationships": [] + } + """), + + // Second module symbol graph + TextFile(name: "ModuleB.symbols.json", utf8Content: """ + { + "metadata": { + "formatVersion": { + "major": 1, + "minor": 0, + "patch": 0 + }, + "generator": "unit-test" + }, + "module": { + "name": "ModuleB", + "platform": { + "architecture": "x86_64", + "vendor": "apple", + "operatingSystem": { + "name": "macosx", + "minimumVersion": { + "major": 10, + "minor": 15 + } + }, + "environment": null + } + }, + "symbols": [ + { + "identifier": { + "precise": "ModuleB", + "interfaceLanguage": "swift" + }, + "names": { + "title": "ModuleB", + "navigator": null, + "subHeading": null, + "prose": null + }, + "pathComponents": ["ModuleB"], + "docComment": null, + "accessLevel": "public", + "kind": { + "identifier": "module", + "displayName": "Module" + }, + "mixins": {} + } + ], + "relationships": [] + } + """), + + InfoPlist(displayName: "TestBundle", identifier: "com.test.example"), + ]) + ) + + // Verify that we emit a warning for multiple main modules + let multipleModulesProblem = try XCTUnwrap(context.problems.first(where: { $0.diagnostic.identifier == "org.swift.docc.MultipleMainModules" })) + XCTAssertEqual(multipleModulesProblem.diagnostic.severity, .warning) + XCTAssertTrue(multipleModulesProblem.diagnostic.summary.contains("more than one main module")) + XCTAssertTrue(multipleModulesProblem.diagnostic.explanation?.contains("ModuleA, ModuleB") == true) + + // Verify the warning doesn't have a source location since it's about the overall input structure + XCTAssertNil(multipleModulesProblem.diagnostic.source) + XCTAssertNil(multipleModulesProblem.diagnostic.range) + } }