Skip to content

Commit 668da58

Browse files
committed
Make ParametersDocumentationExtractor members top-level & remove firstTextContent parameter
1 parent 83e1522 commit 668da58

File tree

4 files changed

+263
-271
lines changed

4 files changed

+263
-271
lines changed

Sources/SwiftLanguageService/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ add_library(SwiftLanguageService STATIC
4646
GeneratedInterfaceManager.swift
4747
SignatureHelp.swift
4848
AdjustPositionToStartOfArgument.swift
49-
ParametersDocumentationExtractor.swift
49+
ExtractParametersDocumentation.swift
5050
)
5151
set_target_properties(SwiftLanguageService PROPERTIES
5252
INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY})
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2025 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 Foundation
14+
import Markdown
15+
16+
private struct Parameter {
17+
let name: String
18+
let documentation: String
19+
}
20+
21+
/// Extracts parameter documentation from a Doxygen parameter command.
22+
private func extractParameter(from doxygenParameter: DoxygenParameter) -> Parameter {
23+
return Parameter(
24+
name: doxygenParameter.name,
25+
documentation: Document(doxygenParameter.blockChildren).format(),
26+
)
27+
}
28+
29+
/// Extracts parameter documentation from an unordered list.
30+
///
31+
/// - Returns: A new ``UnorderedList`` with the items that were not added to the parameters if any.
32+
private func extractParameters(
33+
from unorderedList: UnorderedList
34+
) -> (remaining: UnorderedList?, parameters: [Parameter]) {
35+
var parameters: [Parameter] = []
36+
var newItems: [ListItem] = []
37+
38+
for item in unorderedList.listItems {
39+
if let param = extractSingleParameter(from: item) {
40+
parameters.append(param)
41+
} else if let params = extractParametersOutline(from: item) {
42+
parameters.append(contentsOf: params)
43+
} else {
44+
newItems.append(item)
45+
}
46+
}
47+
48+
if newItems.isEmpty {
49+
return (remaining: nil, parameters: parameters)
50+
}
51+
52+
return (remaining: UnorderedList(newItems), parameters: parameters)
53+
}
54+
55+
/// Parameter documentation from a `Parameters:` outline.
56+
///
57+
/// Example:
58+
/// ```markdown
59+
/// - Parameters:
60+
/// - param: description
61+
/// ```
62+
///
63+
/// - Returns: The extracted parameters if any, nil otherwise.
64+
private func extractParametersOutline(from listItem: ListItem) -> [Parameter]? {
65+
guard
66+
let firstChild = listItem.child(at: 0) as? Paragraph,
67+
let headingText = firstChild.child(at: 0) as? Text,
68+
headingText.string.trimmingCharacters(in: .whitespaces).lowercased().hasPrefix("parameters:")
69+
else {
70+
return nil
71+
}
72+
73+
return listItem.children.flatMap { child in
74+
guard let nestedList = child as? UnorderedList else {
75+
return [] as [Parameter]
76+
}
77+
78+
return nestedList.listItems.compactMap { extractParameter(listItem: $0) }
79+
}
80+
}
81+
82+
/// Extracts parameter documentation from a single parameter.
83+
///
84+
/// Example:
85+
/// ```markdown
86+
/// - Parameter param: description
87+
/// ```
88+
///
89+
/// - Returns: The extracted parameter if any, nil otherwise.
90+
private func extractSingleParameter(from listItem: ListItem) -> Parameter? {
91+
guard let paragraph = listItem.child(at: 0) as? Paragraph,
92+
let paragraphText = paragraph.child(at: 0) as? Text
93+
else {
94+
return nil
95+
}
96+
97+
let parameterPrefix = "parameter "
98+
let paragraphContent = paragraphText.string
99+
100+
guard
101+
let prefixEnd = paragraphContent.index(
102+
paragraphContent.startIndex,
103+
offsetBy: parameterPrefix.count,
104+
limitedBy: paragraphContent.endIndex
105+
)
106+
else {
107+
return nil
108+
}
109+
110+
let potentialMatch = paragraphContent[..<prefixEnd].lowercased()
111+
112+
guard potentialMatch == parameterPrefix else {
113+
return nil
114+
}
115+
116+
let remainingContent = String(paragraphContent[prefixEnd...]).trimmingCharacters(in: .whitespaces)
117+
118+
// Remove the "Parameter " prefix from the list item so we can extract the parameter's documentation using `extractParameter(listItem:)`
119+
var remainingParagraph = paragraph
120+
if remainingContent.isEmpty {
121+
// Drop the Text node if it's empty. This allows `extractParameterWithRawIdentifier` to handle both single parameters
122+
// and parameter outlines uniformly.
123+
remainingParagraph.replaceChildrenInRange(0..<1, with: [])
124+
} else {
125+
remainingParagraph.replaceChildrenInRange(0..<1, with: [Text(remainingContent)])
126+
}
127+
128+
var remainingListItem = listItem
129+
remainingListItem.replaceChildrenInRange(0..<1, with: [remainingParagraph])
130+
131+
return extractParameter(listItem: remainingListItem)
132+
}
133+
134+
/// Extracts a parameter field from a list item.
135+
///
136+
/// - Parameters:
137+
/// - listItem: The list item to extract the parameter from
138+
///
139+
/// - Returns: The extracted parameter if any, nil otherwise.
140+
private func extractParameter(listItem: ListItem) -> Parameter? {
141+
guard let paragraph = listItem.child(at: 0) as? Paragraph else {
142+
return nil
143+
}
144+
145+
guard let firstText = paragraph.child(at: 0) as? Text else {
146+
return extractParameterWithRawIdentifier(from: listItem)
147+
}
148+
149+
let components = firstText.string.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
150+
151+
guard components.count == 2 else {
152+
return extractParameterWithRawIdentifier(from: listItem)
153+
}
154+
155+
let name = String(components[0]).trimmingCharacters(in: .whitespaces)
156+
guard !name.isEmpty else {
157+
return nil
158+
}
159+
160+
let remainingFirstTextContent = String(components[1]).trimmingCharacters(in: .whitespaces)
161+
let remainingParagraphChildren = [Text(remainingFirstTextContent)] + paragraph.inlineChildren.dropFirst()
162+
let remainingChildren = [Paragraph(remainingParagraphChildren)] + listItem.blockChildren.dropFirst()
163+
let documentation = Document(remainingChildren).format()
164+
165+
return Parameter(name: name, documentation: documentation)
166+
}
167+
168+
/// Extracts a parameter with its name as a raw identifier.
169+
///
170+
/// Example:
171+
/// ```markdown
172+
/// - Parameter `foo bar`: documentation
173+
/// - Parameters:
174+
/// - `foo bar`: documentation
175+
/// ```
176+
///
177+
/// - Parameters:
178+
/// - listItem: The list item to extract the parameter from
179+
private func extractParameterWithRawIdentifier(from listItem: ListItem) -> Parameter? {
180+
guard let paragraph = listItem.child(at: 0) as? Paragraph,
181+
let rawIdentifier = paragraph.child(at: 0) as? InlineCode,
182+
let text = paragraph.child(at: 1) as? Text
183+
else {
184+
return nil
185+
}
186+
187+
let textContent = text.string.trimmingCharacters(in: .whitespaces)
188+
189+
guard textContent.hasPrefix(":") else {
190+
return nil
191+
}
192+
193+
let remainingTextContent = String(textContent.dropFirst()).trimmingCharacters(in: .whitespaces)
194+
let remainingParagraphChildren =
195+
[Text(remainingTextContent)] + paragraph.inlineChildren.dropFirst(2)
196+
let remainingChildren = [Paragraph(remainingParagraphChildren)] + listItem.blockChildren.dropFirst(1)
197+
let documentation = Document(remainingChildren).format()
198+
199+
return Parameter(name: rawIdentifier.code, documentation: documentation)
200+
}
201+
202+
/// Extracts parameter documentation from markdown text.
203+
///
204+
/// The parameter extraction implementation is almost ported from the implementation in the Swift compiler codebase.
205+
///
206+
/// The problem with doing that in the Swift compiler codebase is that once you parse a the comment as markdown into
207+
/// a `Document` you cannot easily convert it back into markdown (we'd need to write our own markdown formatter).
208+
/// Besides, `cmark` doesn't handle Doxygen commands.
209+
///
210+
/// We considered using `swift-docc` but we faced some problems with it:
211+
///
212+
/// 1. We would need to refactor existing use of `swift-docc` in SourceKit-LSP to reuse some of that logic here besides
213+
/// providing the required arguments.
214+
/// 2. The result returned by DocC can't be directly converted to markdown, we'd need to provide our own DocC markdown renderer.
215+
///
216+
/// Implementing this using `swift-markdown` allows us to easily parse the comment, process it, convert it back to markdown.
217+
/// It also provides minimal parsing for Doxygen commands (we're only interested in `\param`) allowing us to use the same
218+
/// implementation for Clang-based declarations.
219+
///
220+
/// Although this approach involves code duplication, it's simple enough for the initial implementation. We should consider
221+
/// `swift-docc` in the future.
222+
///
223+
/// - Parameter markdown: The markdown text to extract parameters from
224+
/// - Returns: A tuple containing the extracted parameters dictionary and the remaining markdown text
225+
package func extractParametersDocumentation(
226+
from markdown: String
227+
) -> (parameters: [String: String], remaining: String) {
228+
let document = Document(parsing: markdown, options: [.parseBlockDirectives, .parseMinimalDoxygen])
229+
230+
var parameters: [String: String] = [:]
231+
var remainingBlocks: [any BlockMarkup] = []
232+
233+
for block in document.blockChildren {
234+
switch block {
235+
case let unorderedList as UnorderedList:
236+
let (newUnorderedList, params) = extractParameters(from: unorderedList)
237+
if let newUnorderedList {
238+
remainingBlocks.append(newUnorderedList)
239+
}
240+
241+
for param in params {
242+
// If duplicate parameter documentation is found, keep the first one following swift-docc's behavior
243+
parameters[param.name] = parameters[param.name] ?? param.documentation
244+
}
245+
246+
case let doxygenParameter as DoxygenParameter:
247+
let param = extractParameter(from: doxygenParameter)
248+
// If duplicate parameter documentation is found, keep the first one following swift-docc's behavior
249+
parameters[param.name] = parameters[param.name] ?? param.documentation
250+
251+
default:
252+
remainingBlocks.append(block)
253+
}
254+
}
255+
256+
let remaining = Document(remainingBlocks).format()
257+
258+
return (parameters: parameters, remaining: remaining)
259+
}

0 commit comments

Comments
 (0)