diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index 0e79c65d5..4c9cc5c37 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -3207,19 +3207,56 @@ extension DocumentationContext { var problems = [Problem]() func listSourceLanguages(_ sourceLanguages: Set) -> String { - return sourceLanguages.map(\.name).list(finalConjunction: .and) + sourceLanguages.map(\.name).list(finalConjunction: .and) + } + func removeAlternateRepresentationSolution(_ alternateRepresentation: AlternateRepresentation) -> [Solution] { + [Solution( + summary: "Remove this alternate representation", + replacements: alternateRepresentation.originalMarkup.range.flatMap { [Replacement(range: $0, replacement: "")] } ?? [])] } for node in topicGraph.nodes.values { - guard let entity = try? self.entity(with: node.reference) else { continue } + guard let entity = try? self.entity(with: node.reference), let alternateRepresentations = entity.metadata?.alternateRepresentations else { continue } var sourceLanguageToReference: [SourceLanguage: AlternateRepresentation] = [:] - for alternateRepresentation in entity.metadata?.alternateRepresentations ?? [] { + for alternateRepresentation in alternateRepresentations { + // Check if the entity is not a symbol, as only symbols are allowed to specify custom alternate representations + guard entity.symbol != nil else { + problems.append(Problem( + diagnostic: Diagnostic( + source: alternateRepresentation.originalMarkup.range?.source, + severity: .warning, + range: alternateRepresentation.originalMarkup.range, + identifier: "org.swift.docc.AlternateRepresentation.UnsupportedPageKind", + summary: "Custom alternate representations are not supported for page kind \(entity.kind.name.singleQuoted)", + explanation: "Alternate representations are only supported for symbols." + ), + possibleSolutions: removeAlternateRepresentationSolution(alternateRepresentation) + )) + continue + } + guard case .resolved(.success(let alternateRepresentationReference)) = alternateRepresentation.reference, let alternateRepresentationEntity = try? self.entity(with: alternateRepresentationReference) else { continue } + // Check if the resolved entity is not a symbol, as only symbols are allowed as custom alternate representations + guard alternateRepresentationEntity.symbol != nil else { + problems.append(Problem( + diagnostic: Diagnostic( + source: alternateRepresentation.originalMarkup.range?.source, + severity: .warning, + range: alternateRepresentation.originalMarkup.range, + identifier: "org.swift.docc.AlternateRepresentation.UnsupportedPageKind", + summary: "Page kind \(alternateRepresentationEntity.kind.name.singleQuoted) is not allowed as a custom alternate language representation", + explanation: "Symbols can only specify other symbols as custom language representations." + ), + possibleSolutions: removeAlternateRepresentationSolution(alternateRepresentation) + )) + continue + } + // Check if the documented symbol already has alternate representations from in-source annotations. let duplicateSourceLanguages = alternateRepresentationEntity.availableSourceLanguages.intersection(entity.availableSourceLanguages) if !duplicateSourceLanguages.isEmpty { @@ -3238,7 +3275,6 @@ extension DocumentationContext { let duplicateAlternateLanguages = Set(sourceLanguageToReference.keys).intersection(alternateRepresentationEntity.availableSourceLanguages) if !duplicateAlternateLanguages.isEmpty { - let replacements = alternateRepresentation.originalMarkup.range.flatMap { [Replacement(range: $0, replacement: "")] } ?? [] let notes: [DiagnosticNote] = duplicateAlternateLanguages.compactMap { duplicateAlternateLanguage in guard let alreadyExistingRepresentation = sourceLanguageToReference[duplicateAlternateLanguage], let range = alreadyExistingRepresentation.originalMarkup.range, @@ -3258,7 +3294,7 @@ extension DocumentationContext { explanation: "Only one custom alternate language representation can be specified per language.", notes: notes ), - possibleSolutions: [Solution(summary: "Remove this alternate representation", replacements: replacements)] + possibleSolutions: removeAlternateRepresentationSolution(alternateRepresentation) )) } diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift index 5cf247048..acc097ab5 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift @@ -5365,6 +5365,7 @@ let expected = """ # ``Symbol`` @Metadata { @AlternateRepresentation(``CounterpartSymbol``) + @AlternateRepresentation(OtherCounterpartSymbol) @AlternateRepresentation(``MissingSymbol``) } A symbol extension file defining an alternate representation. @@ -5387,30 +5388,46 @@ let expected = """ ] ) ), + JSONFile( + name: "unit-test.js.symbols.json", + content: makeSymbolGraph( + moduleName: "unit-test", + symbols: [ + makeSymbol(id: "other-counterpart-symbol-id", language: .javaScript, kind: .class, pathComponents: ["OtherCounterpartSymbol"]), + ] + ) + ), ] )) let reference = ResolvedTopicReference(bundleID: bundle.id, path: "/documentation/unit-test/Symbol", sourceLanguage: .swift) let entity = try context.entity(with: reference) - XCTAssertEqual(entity.metadata?.alternateRepresentations.count, 2) + XCTAssertEqual(entity.metadata?.alternateRepresentations.count, 3) - // First counterpart should have been resolved successfully + // First alternate representation should have been resolved successfully var alternateRepresentation = try XCTUnwrap(entity.metadata?.alternateRepresentations.first) XCTAssertEqual( alternateRepresentation.reference, .resolved(.success(.init(bundleID: bundle.id, path: "/documentation/unit-test/CounterpartSymbol", sourceLanguage: .objectiveC))) ) - // Second counterpart shouldn't have been resolved at all - alternateRepresentation = try XCTUnwrap(entity.metadata?.alternateRepresentations[1]) + // Second alternate representation without "``" should also have been resolved successfully + alternateRepresentation = try XCTUnwrap(entity.metadata?.alternateRepresentations.dropFirst().first) + XCTAssertEqual( + alternateRepresentation.reference, + .resolved(.success(.init(bundleID: bundle.id, path: "/documentation/unit-test/OtherCounterpartSymbol", sourceLanguage: .objectiveC))) + ) + + // Third alternate representation shouldn't have been resolved at all + alternateRepresentation = try XCTUnwrap(entity.metadata?.alternateRepresentations.dropFirst().last) guard case .resolved(.failure(let unresolvedPath, _)) = alternateRepresentation.reference else { XCTFail("Expected alternate representation to be unresolved, but was resolved as \(alternateRepresentation.reference)") return } XCTAssertEqual(unresolvedPath, .init(topicURL: .init(parsingAuthoredLink: "MissingSymbol")!)) - // And an error should have been reportes + // And an error should have been reported XCTAssertEqual(context.problems.count, 1) let problem = try XCTUnwrap(context.problems.first) @@ -5418,7 +5435,7 @@ let expected = """ XCTAssertEqual(problem.diagnostic.summary, "Can't resolve 'MissingSymbol'") } - func testDiagnosesAlternateDeclarations() throws { + func testDiagnosesSymbolAlternateDeclarations() throws { let (_, context) = try loadBundle(catalog: Folder( name: "unit-test.docc", content: [ @@ -5488,6 +5505,67 @@ let expected = """ XCTAssertEqual(solution.replacements.count, 1) XCTAssertEqual(solution.replacements.first?.replacement, "") } + + func testDiagnosesArticleAlternateDeclarations() throws { + let (_, context) = try loadBundle(catalog: Folder( + name: "unit-test.docc", + content: [ + TextFile(name: "Symbol.md", utf8Content: """ + # ``Symbol`` + @Metadata { + @AlternateRepresentation("doc:Article") + } + A symbol extension file specifying an alternate representation which is an article. + """), + TextFile(name: "Article.md", utf8Content: """ + # Article + @Metadata { + @AlternateRepresentation(``Symbol``) + } + An article specifying a custom alternate representation. + """), + JSONFile( + name: "unit-test.occ.symbols.json", + content: makeSymbolGraph( + moduleName: "unit-test", + symbols: [ + makeSymbol(id: "symbol-id", kind: .class, pathComponents: ["Symbol"]), + ] + ) + ) + ] + )) + + let alternateRepresentationProblems = context.problems.sorted(by: \.diagnostic.summary) + XCTAssertEqual(alternateRepresentationProblems.count, 2) + + // Verify a problem is reported for trying to define an alternate representation for a language the symbol already supports + var problem = try XCTUnwrap(alternateRepresentationProblems.first) + XCTAssertEqual(problem.diagnostic.severity, .warning) + XCTAssertEqual(problem.diagnostic.summary, "Custom alternate representations are not supported for page kind 'Article'") + XCTAssertEqual(problem.diagnostic.explanation, "Alternate representations are only supported for symbols.") + XCTAssertEqual(problem.possibleSolutions.count, 1) + + // Verify solutions provide context and suggest to remove the invalid directive + var solution = try XCTUnwrap(problem.possibleSolutions.first) + XCTAssertEqual(solution.summary, "Remove this alternate representation") + XCTAssertEqual(solution.replacements.count, 1) + XCTAssertEqual(solution.replacements.first?.replacement, "") + + // Verify a problem is reported for having alternate representations with duplicate source languages + problem = try XCTUnwrap(alternateRepresentationProblems[1]) + XCTAssertEqual(problem.diagnostic.severity, .warning) + XCTAssertEqual(problem.diagnostic.summary, "Page kind 'Article' is not allowed as a custom alternate language representation") + XCTAssertEqual(problem.diagnostic.explanation, "Symbols can only specify other symbols as custom language representations.") + XCTAssertEqual(problem.possibleSolutions.count, 1) + + // Verify solutions provide context and suggest to remove the invalid directive + solution = try XCTUnwrap(problem.possibleSolutions.first) + XCTAssertEqual(solution.summary, "Remove this alternate representation") + XCTAssertEqual(solution.replacements.count, 1) + XCTAssertEqual(solution.replacements.first?.replacement, "") + } + } func assertEqualDumps(_ lhs: String, _ rhs: String, file: StaticString = #file, line: UInt = #line) {