From d260243f060a1109b72dcc847d8a9db005c780eb Mon Sep 17 00:00:00 2001 From: Agisilaos Tsaraboulidis Date: Fri, 15 Aug 2025 09:31:32 +0200 Subject: [PATCH 1/6] Add warnings for multiple root pages --- .../Infrastructure/DocumentationContext.swift | 48 +++++ .../DocumentationContext+RootPageTests.swift | 171 ++++++++++++++++++ 2 files changed, 219 insertions(+) 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) + } } From 665321f5723aedf24ecee8ab6364f5eaa92fe0ff Mon Sep 17 00:00:00 2001 From: Agisilaos Tsaraboulidis Date: Mon, 18 Aug 2025 16:32:59 +0200 Subject: [PATCH 2/6] Add warning for symbols with @TechnologyRoot pages When documentation contains symbols (from symbol graphs) and also has @TechnologyRoot pages, emit a warning about the unsupported setup with multiple roots in the documentation hierarchy. The solution suggests removing the @TechnologyRoot directive so pages are treated as articles under the module instead. --- .../Infrastructure/DocumentationContext.swift | 33 ++++++++++++++ .../DocumentationContext+RootPageTests.swift | 44 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index 83a94dc89..a784199d3 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -2429,6 +2429,39 @@ public class DocumentationContext { return node.reference } + // Warn when the documentation contains both symbols (modules) and @TechnologyRoot pages + // This is an unsupported setup that creates multiple roots in the documentation hierarchy + if !rootModules.isEmpty && !rootPageArticles.isEmpty { + let problems = rootPageArticles.map { rootPageArticle -> Problem in + let diagnostic = Diagnostic( + source: rootPageArticle.source, + severity: .warning, + range: rootPageArticle.value.metadata?.technologyRoot?.originalMarkup.range, + identifier: "org.swift.docc.TechnologyRootWithSymbols", + summary: "Documentation contains both symbols and articles with @TechnologyRoot directives", + explanation: """ + When documentation contains symbols (from symbol graph files), @TechnologyRoot directives create an unsupported setup with multiple roots in the documentation hierarchy. + Remove the @TechnologyRoot directive so that this page is treated as an article under the module. + """ + ) + + 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(problems) + } + // Articles that will be automatically curated can be resolved but they need to be pre registered before resolving links. let rootNodeForAutomaticCuration = soleRootModuleReference.flatMap(topicGraph.nodeWithReference(_:)) if configuration.convertServiceConfiguration.allowsRegisteringArticlesWithoutTechnologyRoot || rootNodeForAutomaticCuration != nil { diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift index 5b73d1b1c..03dbf1c4f 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift @@ -218,6 +218,50 @@ class DocumentationContext_RootPageTests: XCTestCase { } } + func testWarnsAboutSymbolsWithTechnologyRootPages() async throws { + // Test the third case: documentation contains symbols (has a module) and also has @TechnologyRoot pages + let (_, context) = try await loadBundle(catalog: + Folder(name: "symbols-with-root.docc", content: [ + // Symbol graph with a module + JSONFile(name: "MyModule.symbols.json", content: makeSymbolGraph(moduleName: "MyModule")), + + // Article with @TechnologyRoot directive + TextFile(name: "GettingStarted.md", utf8Content: """ + # Getting Started + @Metadata { + @TechnologyRoot + } + Learn how to use MyModule. + """), + + // Another article with @TechnologyRoot directive + TextFile(name: "Overview.md", utf8Content: """ + # Overview + @Metadata { + @TechnologyRoot + } + Overview of the technology. + """), + ]) + ) + + // Verify that we emit warnings for @TechnologyRoot directives when symbols are present + let symbolsWithRootProblems = context.problems.filter { $0.diagnostic.identifier == "org.swift.docc.TechnologyRootWithSymbols" } + XCTAssertEqual(symbolsWithRootProblems.count, 2, "Should emit warnings for both @TechnologyRoot directives when symbols are present") + + // Verify the warnings are associated with the correct files + let problemSources = symbolsWithRootProblems.compactMap { $0.diagnostic.source?.lastPathComponent }.sorted() + XCTAssertEqual(problemSources, ["GettingStarted.md", "Overview.md"]) + + // Verify each warning has a solution to remove the TechnologyRoot directive + for problem in symbolsWithRootProblems { + XCTAssertEqual(problem.possibleSolutions.count, 1) + let solution = try XCTUnwrap(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: From ded847869ccef39123e73c802507944e36c5e8da Mon Sep 17 00:00:00 2001 From: Agisilaos Tsaraboulidis Date: Mon, 18 Aug 2025 16:37:22 +0200 Subject: [PATCH 3/6] Replace raw JSON with makeSymbolGraph() helper Use makeSymbolGraph() helper instead of raw JSON strings for cleaner and more maintainable test code, as suggested in PR review. --- .../DocumentationContext+RootPageTests.swift | 102 +----------------- 1 file changed, 2 insertions(+), 100 deletions(-) diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift index 03dbf1c4f..2bd6d4410 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift @@ -267,108 +267,10 @@ class DocumentationContext_RootPageTests: XCTestCase { 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": [] - } - """), + JSONFile(name: "ModuleA.symbols.json", content: makeSymbolGraph(moduleName: "ModuleA")), // 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": [] - } - """), + JSONFile(name: "ModuleB.symbols.json", content: makeSymbolGraph(moduleName: "ModuleB")), InfoPlist(displayName: "TestBundle", identifier: "com.test.example"), ]) From aa33e18764341645ad57680935b6ccaf26f9a078 Mon Sep 17 00:00:00 2001 From: Agisilaos Tsaraboulidis Date: Mon, 18 Aug 2025 16:37:50 +0200 Subject: [PATCH 4/6] Replace force unwrapping with XCTUnwrap for safer tests Use XCTUnwrap() instead of force unwrapping to handle potential nil values more gracefully and prevent test traps, as suggested in PR review. --- .../DocumentationContext+RootPageTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift index 2bd6d4410..f75e9e1ea 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift @@ -212,7 +212,7 @@ class DocumentationContext_RootPageTests: XCTestCase { // 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! + let solution = try XCTUnwrap(problem.possibleSolutions.first) XCTAssertEqual(solution.summary, "Remove the 'TechnologyRoot' directive") XCTAssertEqual(solution.replacements.count, 1) } From 4ec94c05055420a9fec322413951a672d6cf414d Mon Sep 17 00:00:00 2001 From: Agisilaos Tsaraboulidis Date: Mon, 18 Aug 2025 16:38:58 +0200 Subject: [PATCH 5/6] Remove unnecessary Info.plist from test Remove Info.plist file from testWarnsAboutMultipleTechnologyRootDirectives as it's not needed for this test, per PR review feedback. --- .../DocumentationContext+RootPageTests.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift index f75e9e1ea..cd75477f9 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift @@ -196,8 +196,6 @@ class DocumentationContext_RootPageTests: XCTestCase { } This is the third root page. """), - - InfoPlist(displayName: "TestBundle", identifier: "com.test.example"), ]) ) From 121c159836c710e19c7d50cd66b8a6418a1c605d Mon Sep 17 00:00:00 2001 From: Agisilaos Tsaraboulidis Date: Mon, 18 Aug 2025 16:47:19 +0200 Subject: [PATCH 6/6] Add DiagnosticNote to make warnings more actionable Enhance the "symbols with @TechnologyRoot pages" warning by adding DiagnosticNote entries that point to the symbol graph files causing the multiple roots issue. This helps developers identify exactly which symbol graph files are part of the problem. Also update the test to verify the diagnostic notes are present. Based on PR review feedback. --- .../Infrastructure/DocumentationContext.swift | 14 +++++++++++++- .../DocumentationContext+RootPageTests.swift | 6 ++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index a784199d3..d30a2b18a 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -2433,6 +2433,17 @@ public class DocumentationContext { // This is an unsupported setup that creates multiple roots in the documentation hierarchy if !rootModules.isEmpty && !rootPageArticles.isEmpty { let problems = rootPageArticles.map { rootPageArticle -> Problem in + // Create notes pointing to symbol graph files that are causing the multiple roots issue + let symbolGraphNotes: [DiagnosticNote] = bundle.symbolGraphURLs.map { symbolGraphURL in + let fileName = symbolGraphURL.lastPathComponent + let zeroRange = SourceLocation(line: 1, column: 1, source: nil)..