Skip to content

Commit cffb7da

Browse files
authored
Improve the diagnostics about cyclic curation (#984)
* Improve diagnostics about disallowed cyclic curation rdar://131477513 * Shorten cyclic diagnostic summary message Add debug assert when task group link has no list item Use consistent arrow thickness in cyclic diagnostic explanation Fix grammar in internal code comment
1 parent b54cb7a commit cffb7da

File tree

2 files changed

+141
-5
lines changed

2 files changed

+141
-5
lines changed

Sources/SwiftDocC/Infrastructure/DocumentationCurator.swift

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,22 @@ struct DocumentationCurator {
231231
(childDocumentationNode.kind == .article || childDocumentationNode.kind.isSymbol || childDocumentationNode.kind == .tutorial || childDocumentationNode.kind == .tutorialArticle) else {
232232
continue
233233
}
234+
235+
// A solution that suggests removing the list item that contain this link
236+
var removeListItemSolutions: [Solution] {
237+
// Traverse the markup parents up to the nearest list item
238+
guard let listItem = sequence(first: link as (any Markup), next: \.parent).mapFirst(where: { $0 as? ListItem }),
239+
let listItemRange = listItem.range
240+
else {
241+
assertionFailure("Unable to find the list item element that contains \(link.format()) in the markup for \(describeForDiagnostic(nodeReference).singleQuoted)")
242+
return []
243+
}
244+
return [Solution(
245+
summary: "Remove \(listItem.format().trimmingCharacters(in: .whitespacesAndNewlines).singleQuoted)",
246+
replacements: [Replacement(range: listItemRange, replacement: "")]
247+
)]
248+
}
249+
let topicSectionBaseExplanation = "Links in a \"Topics section\" are used to organize documentation into a hierarchy"
234250

235251
// Allow curating a module node from a manual technology root.
236252
if childDocumentationNode.kind == .module {
@@ -242,19 +258,51 @@ struct DocumentationCurator {
242258
let hasTechnologyRoot = isTechnologyRoot(nodeReference) || context.reachableRoots(from: nodeReference).contains(where: isTechnologyRoot)
243259

244260
if !hasTechnologyRoot {
245-
problems.append(Problem(diagnostic: Diagnostic(source: source(), severity: .warning, range: range(), identifier: "org.swift.docc.ModuleCuration", summary: "Linking to \((link.destination ?? "").singleQuoted) from a Topics group in \(nodeReference.absoluteString.singleQuoted) isn't allowed", explanation: "The former is a module, and modules only exist at the root"), possibleSolutions: []))
261+
problems.append(Problem(
262+
diagnostic: Diagnostic(
263+
source: source(), severity: .warning, range: range(), identifier: "org.swift.docc.ModuleCuration",
264+
summary: "Organizing the module \(childReference.lastPathComponent.singleQuoted) under \(describeForDiagnostic(nodeReference).singleQuoted) isn't allowed",
265+
explanation: "\(topicSectionBaseExplanation). Modules should be roots in the documentation hierarchy."),
266+
possibleSolutions: removeListItemSolutions))
246267
continue
247268
}
248269
}
249270

250271
// Verify we are not creating a graph cyclic relationship.
251272
guard childReference != nodeReference else {
252-
problems.append(Problem(diagnostic: Diagnostic(source: source(), severity: .warning, range: range(), identifier: "org.swift.docc.CyclicReference", summary: "A symbol can't link to itself from within its Topics group in \(nodeReference.absoluteString.singleQuoted)"), possibleSolutions: []))
273+
problems.append(Problem(
274+
diagnostic: Diagnostic(
275+
source: source(), severity: .warning, range: range(), identifier: "org.swift.docc.CyclicReference",
276+
summary: "Organizing \(describeForDiagnostic(childReference).singleQuoted) under itself forms a cycle",
277+
explanation: "\(topicSectionBaseExplanation). The documentation hierarchy shouldn't contain cycles."),
278+
possibleSolutions: removeListItemSolutions
279+
))
253280
continue
254281
}
255282

256283
guard !isReference(childReference, anAncestorOf: nodeReference) else {
257-
problems.append(Problem(diagnostic: Diagnostic(source: source(), severity: .warning, range: range(), identifier: "org.swift.docc.CyclicReference", summary: "Linking to \((link.destination ?? "").singleQuoted) from a Topics group in \(nodeReference.absoluteString.singleQuoted) isn't allowed", explanation: "The former is an ancestor of the latter"), possibleSolutions: []))
284+
// Adding this edge in the topic graph _would_ introduce a cycle.
285+
// In order to produce more actionable diagnostics, create a new graph that has this cycle.
286+
var edges = context.topicGraph.edges
287+
edges[nodeReference, default: []].append(childReference)
288+
let graph = DirectedGraph(edges: edges)
289+
290+
let cycleDescriptions: [String] = graph.cycles(from: nodeReference).map { prettyPrint(cycle: $0) }
291+
292+
problems.append(Problem(
293+
diagnostic: Diagnostic(
294+
source: source(), severity: .warning, range: range(), identifier: "org.swift.docc.CyclicReference",
295+
summary: """
296+
Organizing \(describeForDiagnostic(childReference).singleQuoted) under \(describeForDiagnostic(nodeReference).singleQuoted) \
297+
forms \(cycleDescriptions.count == 1 ? "a cycle" : "\(cycleDescriptions.count) cycles")
298+
""",
299+
explanation: """
300+
\(topicSectionBaseExplanation). The documentation hierarchy shouldn't contain cycles.
301+
If this link contributed to the documentation hierarchy it would introduce \(cycleDescriptions.count == 1 ? "this cycle" : "these \(cycleDescriptions.count) cycles"):
302+
\(cycleDescriptions.joined(separator: "\n"))
303+
"""),
304+
possibleSolutions: removeListItemSolutions
305+
))
258306
continue
259307
}
260308

@@ -272,3 +320,18 @@ struct DocumentationCurator {
272320
}
273321
}
274322
}
323+
324+
private func prettyPrint(cycle: [ResolvedTopicReference]) -> String {
325+
let pathDescription = cycle.map { describeForDiagnostic($0, withoutModuleName: true)}.joined(separator: " ─▶︎ ")
326+
return """
327+
╭─▶︎ \(pathDescription) ─╮
328+
\(String(repeating:"", count: pathDescription.count + 3 /* "─▶︎ "*/ + 2 /* " ─"*/))
329+
"""
330+
}
331+
332+
private func describeForDiagnostic(_ reference: ResolvedTopicReference, withoutModuleName: Bool = false) -> String {
333+
// The references that the curator crawls wouldn't result in a warning if they weren't in the same module.
334+
// To avoid repeating a common prefix. The first two path components are the leading slash and "documentation".
335+
// The third path component is the module name.
336+
reference.pathComponents.dropFirst(withoutModuleName ? 3 : 2).joined(separator: "/")
337+
}

Tests/SwiftDocCTests/Infrastructure/DocumentationCuratorTests.swift

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import Foundation
1313
import XCTest
1414
import SymbolKit
1515
@testable import SwiftDocC
16+
import SwiftDocCTestUtilities
1617
import Markdown
1718

1819
class DocumentationCuratorTests: XCTestCase {
@@ -129,8 +130,11 @@ class DocumentationCuratorTests: XCTestCase {
129130
)
130131
XCTAssertEqual(
131132
moduleCurationProblem?.diagnostic.summary,
132-
"Linking to \'doc://org.swift.docc.example/documentation/MyKit\' from a Topics group in \'doc://org.swift.docc.example/documentation/MyKit/MyClass/myFunction()\' isn't allowed"
133+
"Organizing the module 'MyKit' under 'MyKit/MyClass/myFunction()' isn't allowed"
133134
)
135+
XCTAssertEqual(moduleCurationProblem?.diagnostic.explanation, """
136+
Links in a "Topics section" are used to organize documentation into a hierarchy. Modules should be roots in the documentation hierarchy.
137+
""")
134138

135139
let cyclicReferenceProblem = myClassProblems.first(where: { $0.diagnostic.identifier == "org.swift.docc.CyclicReference" })
136140
XCTAssertNotNil(cyclicReferenceProblem)
@@ -141,8 +145,77 @@ class DocumentationCuratorTests: XCTestCase {
141145
)
142146
XCTAssertEqual(
143147
cyclicReferenceProblem?.diagnostic.summary,
144-
"A symbol can't link to itself from within its Topics group in \'doc://org.swift.docc.example/documentation/MyKit/MyClass/myFunction()\'"
148+
"Organizing 'MyKit/MyClass/myFunction()' under itself forms a cycle"
145149
)
150+
XCTAssertEqual(cyclicReferenceProblem?.diagnostic.explanation, """
151+
Links in a "Topics section" are used to organize documentation into a hierarchy. The documentation hierarchy shouldn't contain cycles.
152+
""")
153+
}
154+
155+
func testCyclicCurationDiagnostic() throws {
156+
let tempURL = try createTempFolder(content: [
157+
Folder(name: "unit-test.docc", content: [
158+
// A number of articles with this cyclic curation:
159+
//
160+
// Root──▶First──▶Second──▶Third─┐
161+
// ▲ │
162+
// └────────────────────┘
163+
TextFile(name: "Root.md", utf8Content: """
164+
# Root
165+
166+
@Metadata {
167+
@TechnologyRoot
168+
}
169+
170+
Curate the first article
171+
172+
## Topics
173+
- <doc:First>
174+
"""),
175+
176+
TextFile(name: "First.md", utf8Content: """
177+
# First
178+
179+
Curate the second article
180+
181+
## Topics
182+
- <doc:Second>
183+
"""),
184+
185+
TextFile(name: "Second.md", utf8Content: """
186+
# Second
187+
188+
Curate the third article
189+
190+
## Topics
191+
- <doc:Third>
192+
"""),
193+
194+
TextFile(name: "Third.md", utf8Content: """
195+
# Third
196+
197+
Form a cycle by curating the first article
198+
## Topics
199+
- <doc:First>
200+
"""),
201+
])
202+
])
203+
204+
let (_, _, context) = try loadBundle(from: tempURL)
205+
XCTAssertEqual(context.problems.map(\.diagnostic.identifier), ["org.swift.docc.CyclicReference"])
206+
let curationProblem = try XCTUnwrap(context.problems.first)
207+
208+
XCTAssertEqual(curationProblem.diagnostic.source?.lastPathComponent, "Third.md")
209+
XCTAssertEqual(curationProblem.diagnostic.summary, "Organizing 'unit-test/First' under 'unit-test/Third' forms a cycle")
210+
211+
XCTAssertEqual(curationProblem.diagnostic.explanation, """
212+
Links in a "Topics section" are used to organize documentation into a hierarchy. The documentation hierarchy shouldn't contain cycles.
213+
If this link contributed to the documentation hierarchy it would introduce this cycle:
214+
╭─▶︎ Third ─▶︎ First ─▶︎ Second ─╮
215+
╰─────────────────────────────╯
216+
""")
217+
218+
XCTAssertEqual(curationProblem.possibleSolutions.map(\.summary), ["Remove '- <doc:First>'"])
146219
}
147220

148221
func testModuleUnderTechnologyRoot() throws {

0 commit comments

Comments
 (0)