Skip to content

Commit ab507bb

Browse files
committed
Diagnose duplicate representations of @AlternateRepresentation
If an `@AlternateRepresentation` clashes with already available source languages, this will now be reported as diagnostics. These diagnostics are performed in the final stage of registering a bundle, during the global analysis of the topic graph, where all nodes are available and all links will have been resolved. This is so that we have all the information we need for detecting duplicates. The following cases are detected: - if the symbol the alternate representation is being defined for (the "original" symbol) was already available in one of the languages the counterpart symbol is available in - if the alternate representations have duplicate source languages in common, i.e. if counterpart1 is available in Objective-C and counterpart2 is **also** available in Objective-C. Suggestions will be provided depending on context: - which languages are duplicate - all the languages the symbol is already available in will be available as part of the diagnostic explanation - if the `@AlternateRepresentation` directive is a duplicate, a suggestion will be made to remove it, with a suitable replacement - if the `@AlternateRepresentation` directive is a duplicate, a note pointing to the original directive will be added Example diagnostics: ``` warning: An alternate representation for Swift already exists This node is already available in Swift and Objective-C. SynonymSample.docc/SymbolExtension2.md:4:5: An alternate representation for Swift has already been defined by an @AlternateRepresentation directive. --> SynonymSample.docc/SymbolExtension2.md:5:5-5:57 3 | @metadata { 4 | @AlternateRepresentation(``Synonyms/Synonym-5zxmc``) 5 + @AlternateRepresentation(``Synonyms/Synonym-5zxmc``) | ╰─suggestion: Remove this alternate representation 6 | } 7 | ``` ``` warning: This node already has a representation in Swift This node is already available in Swift. --> SynonymSample.docc/SynonymExtension.md:5:5-5:56 3 | @metadata { 4 | @AlternateRepresentation(``Synonyms/Synonym-1wqxt``) 5 + @AlternateRepresentation(``Synonyms/OtherSynonym``) | ╰─suggestion: Replace the counterpart link with a node which isn't available in Swift 6 | } 7 | ```
1 parent 5d47f78 commit ab507bb

File tree

2 files changed

+156
-1
lines changed

2 files changed

+156
-1
lines changed

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2835,6 +2835,9 @@ public class DocumentationContext {
28352835
}
28362836
}
28372837

2838+
// Run analysis to determine whether manually configured alternate representations are valid.
2839+
analyzeAlternateRepresentations()
2840+
28382841
// Run global ``TopicGraph`` global analysis.
28392842
analyzeTopicGraph()
28402843
}
@@ -3199,6 +3202,87 @@ extension DocumentationContext {
31993202
}
32003203
diagnosticEngine.emit(problems)
32013204
}
3205+
3206+
func analyzeAlternateRepresentations() {
3207+
var problems = [Problem]()
3208+
3209+
func listSourceLanguages(_ sourceLanguages: Set<SourceLanguage>) -> String {
3210+
sourceLanguages.sorted(by: { language1, language2 in
3211+
// Emit Swift first, then alphabetically.
3212+
switch (language1, language2) {
3213+
case (.swift, _): return true
3214+
case (_, .swift): return false
3215+
default: return language1.id < language2.id
3216+
}
3217+
}).map(\.name).list(finalConjunction: .and)
3218+
}
3219+
func removeAlternateRepresentationSolution(_ alternateRepresentation: AlternateRepresentation) -> [Solution] {
3220+
[Solution(
3221+
summary: "Remove this alternate representation",
3222+
replacements: alternateRepresentation.originalMarkup.range.map { [Replacement(range: $0, replacement: "")] } ?? [])]
3223+
}
3224+
3225+
for reference in knownPages {
3226+
guard let entity = try? self.entity(with: reference), let alternateRepresentations = entity.metadata?.alternateRepresentations else { continue }
3227+
3228+
var sourceLanguageToReference: [SourceLanguage: AlternateRepresentation] = [:]
3229+
for alternateRepresentation in entity.metadata?.alternateRepresentations ?? [] {
3230+
guard case .resolved(.success(let alternateRepresentationReference)) = alternateRepresentation.reference,
3231+
let alternateRepresentationEntity = try? self.entity(with: alternateRepresentationReference) else {
3232+
continue
3233+
}
3234+
3235+
// Check if the documented symbol already has alternate representations from in-source annotations.
3236+
let duplicateSourceLanguages = alternateRepresentationEntity.availableSourceLanguages.intersection(entity.availableSourceLanguages)
3237+
if !duplicateSourceLanguages.isEmpty {
3238+
problems.append(Problem(
3239+
diagnostic: Diagnostic(
3240+
source: alternateRepresentation.originalMarkup.range?.source,
3241+
severity: .warning,
3242+
range: alternateRepresentation.originalMarkup.range,
3243+
identifier: "org.swift.docc.AlternateRepresentation.DuplicateLanguageDefinition",
3244+
summary: "\(entity.name.plainText.singleQuoted) already has a representation in \(listSourceLanguages(duplicateSourceLanguages))",
3245+
explanation: "Symbols can only specify custom alternate language representations for languages that the documented symbol doesn't already have a representation for."
3246+
),
3247+
possibleSolutions: [Solution(summary: "Replace this alternate language representation with a symbol which isn't available in \(listSourceLanguages(entity.availableSourceLanguages))", replacements: [])]
3248+
))
3249+
}
3250+
3251+
let duplicateAlternateLanguages = Set(sourceLanguageToReference.keys).intersection(alternateRepresentationEntity.availableSourceLanguages)
3252+
if !duplicateAlternateLanguages.isEmpty {
3253+
let replacements = alternateRepresentation.originalMarkup.range.flatMap { [Replacement(range: $0, replacement: "")] } ?? []
3254+
let notes: [DiagnosticNote] = duplicateAlternateLanguages.compactMap { duplicateAlternateLanguage in
3255+
guard let alreadyExistingRepresentation = sourceLanguageToReference[duplicateAlternateLanguage],
3256+
let range = alreadyExistingRepresentation.originalMarkup.range,
3257+
let source = range.source else {
3258+
return nil
3259+
}
3260+
3261+
return DiagnosticNote(source: source, range: range, message: "This directive already specifies an alternate \(duplicateAlternateLanguage.name) representation.")
3262+
}
3263+
problems.append(Problem(
3264+
diagnostic: Diagnostic(
3265+
source: alternateRepresentation.originalMarkup.range?.source,
3266+
severity: .warning,
3267+
range: alternateRepresentation.originalMarkup.range,
3268+
identifier: "org.swift.docc.AlternateRepresentation.DuplicateLanguageDefinition",
3269+
summary: "A custom alternate language representation for \(listSourceLanguages(duplicateAlternateLanguages)) has already been specified",
3270+
explanation: "Only one custom alternate language representation can be specified per language.",
3271+
notes: notes
3272+
),
3273+
possibleSolutions: [Solution(summary: "Remove this alternate representation", replacements: replacements)]
3274+
))
3275+
}
3276+
3277+
// Update mapping from source language to alternate declaration, for diagnostic purposes
3278+
for alreadySeenLanguage in alternateRepresentationEntity.availableSourceLanguages {
3279+
sourceLanguageToReference[alreadySeenLanguage] = alternateRepresentation
3280+
}
3281+
}
3282+
}
3283+
3284+
diagnosticEngine.emit(problems)
3285+
}
32023286
}
32033287

32043288
extension GraphCollector.GraphKind {

Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5433,7 +5433,78 @@ let expected = """
54335433
let problem = try XCTUnwrap(context.problems.first)
54345434
XCTAssertEqual(problem.diagnostic.severity, .warning)
54355435
XCTAssertEqual(problem.diagnostic.summary, "Can't resolve 'MissingSymbol'")
5436-
}
5436+
}
5437+
5438+
func testDiagnosesAlternateDeclarations() throws {
5439+
let (_, context) = try loadBundle(catalog: Folder(
5440+
name: "unit-test.docc",
5441+
content: [
5442+
TextFile(name: "Symbol.md", utf8Content: """
5443+
# ``Symbol``
5444+
@Metadata {
5445+
@AlternateRepresentation(``CounterpartSymbol``)
5446+
@AlternateRepresentation(``OtherCounterpartSymbol``)
5447+
}
5448+
A symbol extension file defining an alternate representation which overlaps source languages with another one.
5449+
"""),
5450+
TextFile(name: "SwiftSymbol.md", utf8Content: """
5451+
# ``SwiftSymbol``
5452+
@Metadata {
5453+
@AlternateRepresentation(``Symbol``)
5454+
}
5455+
A symbol extension file defining an alternate representation which overlaps source languages with the current node.
5456+
"""),
5457+
JSONFile(
5458+
name: "unit-test.swift.symbols.json",
5459+
content: makeSymbolGraph(
5460+
moduleName: "unit-test",
5461+
symbols: [
5462+
makeSymbol(id: "symbol-id", kind: .class, pathComponents: ["Symbol"]),
5463+
makeSymbol(id: "other-symbol-id", kind: .class, pathComponents: ["SwiftSymbol"]),
5464+
]
5465+
)
5466+
),
5467+
JSONFile(
5468+
name: "unit-test.occ.symbols.json",
5469+
content: makeSymbolGraph(
5470+
moduleName: "unit-test",
5471+
symbols: [
5472+
makeSymbol(id: "counterpart-symbol-id", language: .objectiveC, kind: .class, pathComponents: ["CounterpartSymbol"]),
5473+
makeSymbol(id: "other-counterpart-symbol-id", language: .objectiveC, kind: .class, pathComponents: ["OtherCounterpartSymbol"]),
5474+
]
5475+
)
5476+
),
5477+
]
5478+
))
5479+
5480+
let alternateRepresentationProblems = context.problems.sorted(by: \.diagnostic.summary)
5481+
XCTAssertEqual(alternateRepresentationProblems.count, 2)
5482+
5483+
// Verify a problem is reported for trying to define an alternate representation for a language the symbol already supports
5484+
var problem = try XCTUnwrap(alternateRepresentationProblems.first)
5485+
XCTAssertEqual(problem.diagnostic.severity, .warning)
5486+
XCTAssertEqual(problem.diagnostic.summary, "'SwiftSymbol' already has a representation in Swift")
5487+
XCTAssertEqual(problem.diagnostic.explanation, "Symbols can only specify custom alternate language representations for languages that the documented symbol doesn't already have a representation for.")
5488+
XCTAssertEqual(problem.possibleSolutions.count, 1)
5489+
5490+
// Verify solutions provide context, but no replacements
5491+
var solution = try XCTUnwrap(problem.possibleSolutions.first)
5492+
XCTAssertEqual(solution.summary, "Replace this alternate language representation with a symbol which isn't available in Swift")
5493+
XCTAssertEqual(solution.replacements.count, 0)
5494+
5495+
// Verify a problem is reported for having alternate representations with duplicate source languages
5496+
problem = try XCTUnwrap(alternateRepresentationProblems[1])
5497+
XCTAssertEqual(problem.diagnostic.severity, .warning)
5498+
XCTAssertEqual(problem.diagnostic.summary, "A custom alternate language representation for Objective-C has already been specified")
5499+
XCTAssertEqual(problem.diagnostic.explanation, "Only one custom alternate language representation can be specified per language.")
5500+
XCTAssertEqual(problem.possibleSolutions.count, 1)
5501+
5502+
// Verify solutions provide context and suggest to remove the duplicate directive
5503+
solution = try XCTUnwrap(problem.possibleSolutions.first)
5504+
XCTAssertEqual(solution.summary, "Remove this alternate representation")
5505+
XCTAssertEqual(solution.replacements.count, 1)
5506+
XCTAssertEqual(solution.replacements.first?.replacement, "")
5507+
}
54375508
}
54385509

54395510
func assertEqualDumps(_ lhs: String, _ rhs: String, file: StaticString = #file, line: UInt = #line) {

0 commit comments

Comments
 (0)