Skip to content

Commit 2356675

Browse files
authored
Merge pull request #560 from allevato/markdown
Use swift-markdown to parse documentation comments.
2 parents f957d52 + 1ebb010 commit 2356675

12 files changed

+1045
-275
lines changed

Package.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,18 @@ let package = Package(
6767
name: "SwiftFormatCore",
6868
dependencies: [
6969
"SwiftFormatConfiguration",
70+
.product(name: "Markdown", package: "swift-markdown"),
7071
.product(name: "SwiftOperators", package: "swift-syntax"),
7172
.product(name: "SwiftSyntax", package: "swift-syntax"),
7273
]
7374
),
7475
.target(
7576
name: "SwiftFormatRules",
76-
dependencies: ["SwiftFormatCore", "SwiftFormatConfiguration"]
77+
dependencies: [
78+
"SwiftFormatCore",
79+
"SwiftFormatConfiguration",
80+
.product(name: "Markdown", package: "swift-markdown"),
81+
]
7782
),
7883
.target(
7984
name: "SwiftFormatPrettyPrint",
@@ -155,7 +160,9 @@ let package = Package(
155160
dependencies: [
156161
"SwiftFormatConfiguration",
157162
"SwiftFormatCore",
163+
.product(name: "Markdown", package: "swift-markdown"),
158164
.product(name: "SwiftSyntax", package: "swift-syntax"),
165+
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
159166
.product(name: "SwiftParser", package: "swift-syntax"),
160167
]
161168
),
@@ -216,6 +223,10 @@ if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil {
216223
url: "https://github.com/apple/swift-argument-parser.git",
217224
from: "1.2.2"
218225
),
226+
.package(
227+
url: "https://github.com/apple/swift-markdown.git",
228+
from: "0.2.0"
229+
),
219230
.package(
220231
url: "https://github.com/apple/swift-syntax.git",
221232
branch: "main"
@@ -224,6 +235,7 @@ if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil {
224235
} else {
225236
package.dependencies += [
226237
.package(path: "../swift-argument-parser"),
238+
.package(path: "../swift-markdown"),
227239
.package(path: "../swift-syntax"),
228240
]
229241
}
Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Markdown
14+
import SwiftSyntax
15+
16+
/// A structured representation of information extracted from a documentation comment.
17+
///
18+
/// This type represents both the top-level content of a documentation comment on a declaration and
19+
/// also the nested information that can be provided on a parameter. For example, when a parameter
20+
/// is a function type, it can provide not only a brief summary but also its own parameter and
21+
/// return value descriptions.
22+
public struct DocumentationComment {
23+
/// A description of a parameter in a documentation comment.
24+
public struct Parameter {
25+
/// The name of the parameter.
26+
public var name: String
27+
28+
/// The documentation comment of the parameter.
29+
///
30+
/// Typically, only the `briefSummary` field of this value will be populated. However, for more
31+
/// complex cases like parameters whose types are functions, the grammar permits full
32+
/// descriptions including `Parameter(s)`, `Returns`, and `Throws` fields to be present.
33+
public var comment: DocumentationComment
34+
}
35+
36+
/// Describes the structural layout of the parameter descriptions in the comment.
37+
public enum ParameterLayout {
38+
/// All parameters were written under a single `Parameters` outline section at the top level of
39+
/// the comment.
40+
case outline
41+
42+
/// All parameters were written as individual `Parameter` items at the top level of the comment.
43+
case separated
44+
45+
/// Parameters were written as a combination of one or more `Parameters` outlines and individual
46+
/// `Parameter` items.
47+
case mixed
48+
}
49+
50+
/// A single paragraph representing a brief summary of the declaration, if present.
51+
public var briefSummary: Paragraph? = nil
52+
53+
/// A collection of otherwise uncategorized body nodes at the top level of the comment text.
54+
///
55+
/// If a brief summary paragraph was extracted from the comment, it will not be present in this
56+
/// collection.
57+
public var bodyNodes: [Markup] = []
58+
59+
/// The structural layout of the parameter descriptions in the comment.
60+
public var parameterLayout: ParameterLayout? = nil
61+
62+
/// Descriptions of parameters to a function, if any.
63+
public var parameters: [Parameter] = []
64+
65+
/// A description of the return value of a function.
66+
///
67+
/// If present, this value is a copy of the `Paragraph` node from the comment but with the
68+
/// `Returns:` prefix removed for convenience.
69+
public var returns: Paragraph? = nil
70+
71+
/// A description of an error thrown by a function.
72+
///
73+
/// If present, this value is a copy of the `Paragraph` node from the comment but with the
74+
/// `Throws:` prefix removed for convenience.
75+
public var `throws`: Paragraph? = nil
76+
77+
/// Creates a new `DocumentationComment` with information extracted from the leading trivia of the
78+
/// given syntax node.
79+
///
80+
/// If the syntax node does not have a preceding documentation comment, this initializer returns
81+
/// `nil`.
82+
///
83+
/// - Parameter node: The syntax node from which the documentation comment should be extracted.
84+
public init?<Node: SyntaxProtocol>(extractedFrom node: Node) {
85+
guard let commentInfo = documentationCommentText(extractedFrom: node.leadingTrivia) else {
86+
return nil
87+
}
88+
89+
// Disable smart quotes and dash conversion since we want to preserve the original content of
90+
// the comments instead of doing documentation generation.
91+
let doc = Document(parsing: commentInfo.text, options: [.disableSmartOpts])
92+
self.init(markup: doc)
93+
}
94+
95+
/// Creates a new `DocumentationComment` from the given `Markup` node.
96+
private init(markup: Markup) {
97+
// Extract the first paragraph as the brief summary. It will *not* be included in the body
98+
// nodes.
99+
let remainingChildren: DropFirstSequence<MarkupChildren>
100+
if let firstParagraph = markup.child(through: [(0, Paragraph.self)]) {
101+
briefSummary = firstParagraph.detachedFromParent as? Paragraph
102+
remainingChildren = markup.children.dropFirst()
103+
} else {
104+
briefSummary = nil
105+
remainingChildren = markup.children.dropFirst(0)
106+
}
107+
108+
for child in remainingChildren {
109+
if var list = child.detachedFromParent as? UnorderedList {
110+
// An unordered list could be one of the following:
111+
//
112+
// 1. A parameter outline:
113+
// - Parameters:
114+
// - x: ...
115+
// - y: ...
116+
//
117+
// 2. An exploded parameter list:
118+
// - Parameter x: ...
119+
// - Parameter y: ...
120+
//
121+
// 3. Some other simple field, like `Returns:`.
122+
//
123+
// Note that the order of execution of these two functions matters for the correct value of
124+
// `parameterLayout` to be computed. If these ever change, make sure to update that
125+
// computation inside the functions.
126+
extractParameterOutline(from: &list)
127+
extractSeparatedParameters(from: &list)
128+
129+
extractSimpleFields(from: &list)
130+
131+
// If the list is now empty, don't add it to the body nodes below.
132+
guard !list.isEmpty else { continue }
133+
}
134+
135+
bodyNodes.append(child.detachedFromParent)
136+
}
137+
}
138+
139+
/// Extracts parameter fields in an outlined parameters list (i.e., `- Parameters:` containing a
140+
/// nested list of parameter fields) from the given unordered list.
141+
///
142+
/// If parameters were successfully extracted, the provided list is mutated to remove them as a
143+
/// side effect of this function.
144+
private mutating func extractParameterOutline(from list: inout UnorderedList) {
145+
var unprocessedChildren: [Markup] = []
146+
147+
for child in list.children {
148+
guard
149+
let listItem = child as? ListItem,
150+
let firstText = listItem.child(through: [
151+
(0, Paragraph.self),
152+
(0, Text.self),
153+
]) as? Text,
154+
firstText.string.trimmingCharacters(in: .whitespaces).lowercased() == "parameters:"
155+
else {
156+
unprocessedChildren.append(child.detachedFromParent)
157+
continue
158+
}
159+
160+
for index in 1..<listItem.childCount {
161+
let listChild = listItem.child(at: index)
162+
guard let sublist = listChild as? UnorderedList else { continue }
163+
for sublistItem in sublist.listItems {
164+
guard
165+
let paramField = parameterField(extractedFrom: sublistItem, expectParameterLabel: false)
166+
else {
167+
continue
168+
}
169+
self.parameters.append(paramField)
170+
self.parameterLayout = .outline
171+
}
172+
}
173+
}
174+
175+
list = list.withUncheckedChildren(unprocessedChildren) as! UnorderedList
176+
}
177+
178+
/// Extracts parameter fields in separated form (i.e., individual `- Parameter <name>:` items in
179+
/// a top-level list in the comment text) from the given unordered list.
180+
///
181+
/// If parameters were successfully extracted, the provided list is mutated to remove them as a
182+
/// side effect of this function.
183+
private mutating func extractSeparatedParameters(from list: inout UnorderedList) {
184+
var unprocessedChildren: [Markup] = []
185+
186+
for child in list.children {
187+
guard
188+
let listItem = child as? ListItem,
189+
let paramField = parameterField(extractedFrom: listItem, expectParameterLabel: true)
190+
else {
191+
unprocessedChildren.append(child.detachedFromParent)
192+
continue
193+
}
194+
195+
self.parameters.append(paramField)
196+
197+
switch self.parameterLayout {
198+
case nil:
199+
self.parameterLayout = .separated
200+
case .outline:
201+
self.parameterLayout = .mixed
202+
default:
203+
break
204+
}
205+
}
206+
207+
list = list.withUncheckedChildren(unprocessedChildren) as! UnorderedList
208+
}
209+
210+
/// Returns a new `ParameterField` containing parameter information extracted from the given list
211+
/// item, or `nil` if it was not a valid parameter field.
212+
private func parameterField(
213+
extractedFrom listItem: ListItem,
214+
expectParameterLabel: Bool
215+
) -> Parameter? {
216+
var rewriter = ParameterOutlineMarkupRewriter(
217+
origin: listItem,
218+
expectParameterLabel: expectParameterLabel)
219+
guard
220+
let newListItem = listItem.accept(&rewriter) as? ListItem,
221+
let name = rewriter.parameterName
222+
else { return nil }
223+
224+
return Parameter(name: name, comment: DocumentationComment(markup: newListItem))
225+
}
226+
227+
/// Extracts simple fields like `- Returns:` and `- Throws:` from the top-level list in the
228+
/// comment text.
229+
///
230+
/// If fields were successfully extracted, the provided list is mutated to remove them.
231+
private mutating func extractSimpleFields(from list: inout UnorderedList) {
232+
var unprocessedChildren: [Markup] = []
233+
234+
for child in list.children {
235+
guard
236+
let listItem = child as? ListItem,
237+
case var rewriter = SimpleFieldMarkupRewriter(origin: listItem),
238+
listItem.accept(&rewriter) as? ListItem != nil,
239+
let name = rewriter.fieldName,
240+
let paragraph = rewriter.paragraph
241+
else {
242+
unprocessedChildren.append(child)
243+
continue
244+
}
245+
246+
switch name.lowercased() {
247+
case "returns":
248+
self.returns = paragraph
249+
case "throws":
250+
self.throws = paragraph
251+
default:
252+
unprocessedChildren.append(child)
253+
}
254+
}
255+
256+
list = list.withUncheckedChildren(unprocessedChildren) as! UnorderedList
257+
}
258+
}
259+
260+
/// Visits a list item representing a parameter in a documentation comment and rewrites it to remove
261+
/// any `Parameter` tag (if present), the name of the parameter, and the subsequent colon.
262+
private struct ParameterOutlineMarkupRewriter: MarkupRewriter {
263+
/// The list item to which the rewriter will be applied.
264+
let origin: ListItem
265+
266+
/// If true, the `Parameter` prefix is expected on the list item content and it should be dropped.
267+
let expectParameterLabel: Bool
268+
269+
/// Populated if the list item to which this is applied represents a valid parameter field.
270+
private(set) var parameterName: String? = nil
271+
272+
mutating func visitListItem(_ listItem: ListItem) -> Markup? {
273+
// Only recurse into the exact list item we're applying this to; otherwise, return it unchanged.
274+
guard listItem.isIdentical(to: origin) else { return listItem }
275+
return defaultVisit(listItem)
276+
}
277+
278+
mutating func visitParagraph(_ paragraph: Paragraph) -> Markup? {
279+
// Only recurse into the first paragraph in the list item.
280+
guard paragraph.indexInParent == 0 else { return paragraph }
281+
return defaultVisit(paragraph)
282+
}
283+
284+
mutating func visitText(_ text: Text) -> Markup? {
285+
// Only manipulate the first text node (of the first paragraph).
286+
guard text.indexInParent == 0 else { return text }
287+
288+
let parameterPrefix = "parameter "
289+
if expectParameterLabel && !text.string.lowercased().hasPrefix(parameterPrefix) { return text }
290+
291+
let string =
292+
expectParameterLabel ? text.string.dropFirst(parameterPrefix.count) : text.string[...]
293+
let nameAndRemainder = string.split(separator: ":", maxSplits: 1)
294+
guard nameAndRemainder.count == 2 else { return text }
295+
296+
let name = nameAndRemainder[0].trimmingCharacters(in: .whitespaces)
297+
guard !name.isEmpty else { return text }
298+
299+
self.parameterName = name
300+
return Text(String(nameAndRemainder[1]))
301+
}
302+
}
303+
304+
/// Visits a list item representing a simple field in a documentation comment and rewrites it to
305+
/// extract the field name, removing it and the subsequent colon from the item.
306+
private struct SimpleFieldMarkupRewriter: MarkupRewriter {
307+
/// The list item to which the rewriter will be applied.
308+
let origin: ListItem
309+
310+
/// Populated if the list item to which this is applied represents a valid simple field.
311+
private(set) var fieldName: String? = nil
312+
313+
/// Populated if the list item to which this is applied represents a valid simple field.
314+
private(set) var paragraph: Paragraph? = nil
315+
316+
mutating func visitListItem(_ listItem: ListItem) -> Markup? {
317+
// Only recurse into the exact list item we're applying this to; otherwise, return it unchanged.
318+
guard listItem.isIdentical(to: origin) else { return listItem }
319+
return defaultVisit(listItem)
320+
}
321+
322+
mutating func visitParagraph(_ paragraph: Paragraph) -> Markup? {
323+
// Only recurse into the first paragraph in the list item.
324+
guard paragraph.indexInParent == 0 else { return paragraph }
325+
guard let newNode = defaultVisit(paragraph) else { return nil }
326+
guard let newParagraph = newNode as? Paragraph else { return newNode }
327+
self.paragraph = newParagraph.detachedFromParent as? Paragraph
328+
return newParagraph
329+
}
330+
331+
mutating func visitText(_ text: Text) -> Markup? {
332+
// Only manipulate the first text node (of the first paragraph).
333+
guard text.indexInParent == 0 else { return text }
334+
335+
let nameAndRemainder = text.string.split(separator: ":", maxSplits: 1)
336+
guard nameAndRemainder.count == 2 else { return text }
337+
338+
let name = nameAndRemainder[0].trimmingCharacters(in: .whitespaces)
339+
guard !name.isEmpty else { return text }
340+
341+
self.fieldName = name
342+
return Text(String(nameAndRemainder[1]))
343+
}
344+
}

0 commit comments

Comments
 (0)