Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,7 @@ var targets: [Target] = [
"ToolchainRegistry",
"TSCExtensions",
.product(name: "IndexStoreDB", package: "indexstore-db"),
.product(name: "Markdown", package: "swift-markdown"),
.product(name: "Crypto", package: "swift-crypto"),
.product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"),
]
Expand Down
2 changes: 2 additions & 0 deletions Sources/SwiftLanguageService/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ add_library(SwiftLanguageService STATIC
GeneratedInterfaceManager.swift
SignatureHelp.swift
AdjustPositionToStartOfArgument.swift
ParametersDocumentationExtractor.swift
)
set_target_properties(SwiftLanguageService PROPERTIES
INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY})
Expand All @@ -57,6 +58,7 @@ target_link_libraries(SwiftLanguageService PUBLIC
SourceKitLSP
ToolchainRegistry
IndexStoreDB
Markdown
SwiftSyntax::SwiftBasicFormat
SwiftSyntax::SwiftSyntax
)
Expand Down
192 changes: 192 additions & 0 deletions Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import Foundation
import Markdown

private struct ParametersDocumentationExtractor {
private var parameters = [String: String]()

/// Extracts parameter documentation from a markdown string.
///
/// - Returns: A tuple containing the extracted parameters and the remaining markdown.
mutating func extract(from markdown: String) -> (parameters: [String: String], remaining: String) {
let document = Document(parsing: markdown, options: [.parseBlockDirectives, .parseMinimalDoxygen])

var remainingBlocks = [any BlockMarkup]()

for block in document.blockChildren {
switch block {
case let unorderedList as UnorderedList:
if let newUnorderedList = extract(from: unorderedList) {
remainingBlocks.append(newUnorderedList)
}
case let doxygenParameter as DoxygenParameter:
extract(from: doxygenParameter)
default:
remainingBlocks.append(block)
}
}

let remaining = Document(remainingBlocks).format()

return (parameters, remaining)
}

/// Extracts parameter documentation from a Doxygen parameter command.
private mutating func extract(from doxygenParameter: DoxygenParameter) {
parameters[doxygenParameter.name] = Document(doxygenParameter.blockChildren).format()
}

/// Extracts parameter documentation from an unordered list.
///
/// - Returns: A new UnorderedList with the items that were not added to the parameters if any.
private mutating func extract(from unorderedList: UnorderedList) -> UnorderedList? {
var newItems = [ListItem]()

for item in unorderedList.listItems {
if extractSingle(from: item) || extractOutline(from: item) {
continue
}

newItems.append(item)
}

if newItems.isEmpty {
return nil
}

return UnorderedList(newItems)
}

/// Parameter documentation from a `Parameters:` outline.
///
/// Example:
/// ```markdown
/// - Parameters:
/// - param: description
/// ```
///
/// - Returns: True if the list item has parameter outline documentation, false otherwise.
private mutating func extractOutline(from listItem: ListItem) -> Bool {
guard let firstChild = listItem.child(at: 0) as? Paragraph,
let headingText = firstChild.child(at: 0) as? Text
else {
return false
}

let parametersPrefix = "parameters:"
let headingContent = headingText.string.trimmingCharacters(in: .whitespaces)

guard headingContent.lowercased().hasPrefix(parametersPrefix) else {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We check for a prefix instead of headingContent == parametersPrefix to match the behavior in Xcode.

return false
}

for child in listItem.children {
guard let nestedList = child as? UnorderedList else {
continue
}

for nestedItem in nestedList.listItems {
if let parameter = extractOutlineItem(from: nestedItem) {
parameters[parameter.name] = parameter.documentation
}
}
}

return true
}

/// Extracts parameter documentation from a single parameter.
///
/// Example:
/// ```markdown
/// - Parameter param: description
/// ```
///
/// - Returns: True if the list item has single parameter documentation, false otherwise.
private mutating func extractSingle(from listItem: ListItem) -> Bool {
guard let paragraph = listItem.child(at: 0) as? Paragraph,
let paragraphText = paragraph.child(at: 0) as? Text
else {
return false
}

let parameterPrefix = "parameter "
let paragraphContent = paragraphText.string

guard paragraphContent.count >= parameterPrefix.count else {
return false
}

let prefixEnd = paragraphContent.index(paragraphContent.startIndex, offsetBy: parameterPrefix.count)
let potentialMatch = paragraphContent[..<prefixEnd].lowercased()

guard potentialMatch == parameterPrefix else {
return false
}

let remainingContent = String(paragraphContent[prefixEnd...]).trimmingCharacters(in: .whitespaces)

guard let parameter = extractParam(firstTextContent: remainingContent, listItem: listItem) else {
return false
}

parameters[parameter.name] = parameter.documentation

return true
}

/// Extracts a parameter field from a list item (used for parameter outline items)
private func extractOutlineItem(from listItem: ListItem) -> (name: String, documentation: String)? {
guard let paragraph = listItem.child(at: 0) as? Paragraph else {
return nil
}

guard let paragraphText = paragraph.child(at: 0) as? Text else {
return nil
}

return extractParam(firstTextContent: paragraphText.string, listItem: listItem)
}

/// Extracts a parameter field from a list item provided the relevant first text content allowing reuse in ``extractOutlineItem`` and ``extractSingle``
///
/// - Parameters:
/// - firstTextContent: The content of the first text child of the list item's first paragraph
/// - listItem: The list item to extract the parameter from
///
/// - Returns: A tuple containing the parameter name and documentation if a parameter was found, nil otherwise.
private func extractParam(
firstTextContent: String,
listItem: ListItem
) -> (name: String, documentation: String)? {
guard let paragraph = listItem.child(at: 0) as? Paragraph else {
return nil
}

let components = firstTextContent.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)

guard components.count == 2 else {
return nil
}

let name = String(components[0]).trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else {
return nil
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about raw identifiers e.g - `foo bar`: hello?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added, can you please recheck? 🙏🏼


let remainingFirstTextContent = String(components[1]).trimmingCharacters(in: .whitespaces)
let remainingParagraphChildren = [Text(remainingFirstTextContent)] + paragraph.inlineChildren.dropFirst()
let remainingChildren = [Paragraph(remainingParagraphChildren)] + listItem.blockChildren.dropFirst()
let documentation = Document(remainingChildren).format()

return (name, documentation)
}
}

/// Extracts parameter documentation from markdown text.
///
/// - Parameter markdown: The markdown text to extract parameters from
/// - Returns: A tuple containing the extracted parameters dictionary and the remaining markdown text
package func extractParametersDocumentation(from markdown: String) -> ([String: String], String) {
var extractor = ParametersDocumentationExtractor()
return extractor.extract(from: markdown)
}
35 changes: 24 additions & 11 deletions Sources/SwiftLanguageService/SignatureHelp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,23 @@ fileprivate extension String {
}

fileprivate extension ParameterInformation {
init?(_ parameter: SKDResponseDictionary, _ signatureLabel: String, _ keys: sourcekitd_api_keys) {
init?(
_ parameter: SKDResponseDictionary,
_ signatureLabel: String,
_ keys: sourcekitd_api_keys,
_ parameterDocumentation: [String: String]
) {
guard let nameOffset = parameter[keys.nameOffset] as Int?,
let nameLength = parameter[keys.nameLength] as Int?
else {
return nil
}

let documentation: StringOrMarkupContent? =
if let docComment: String = parameter[keys.docComment] {
.markupContent(MarkupContent(kind: .markdown, value: docComment))
if let name = parameter[keys.name] as String?,
let documentation = parameterDocumentation[name]
{
.markupContent(MarkupContent(kind: .markdown, value: documentation))
} else {
nil
}
Expand All @@ -68,7 +75,20 @@ fileprivate extension SignatureInformation {
return nil
}

let parameters = skParameters.compactMap { ParameterInformation($0, label, keys) }
let documentation: StringOrMarkupContent?
let parameterDocumentation: [String: String]

if let docComment: String = signature[keys.docComment] {
let (parameterComments, signatureComment) = extractParametersDocumentation(from: docComment)

documentation = .markupContent(MarkupContent(kind: .markdown, value: signatureComment))
parameterDocumentation = parameterComments
} else {
documentation = nil
parameterDocumentation = [:]
}

let parameters = skParameters.compactMap { ParameterInformation($0, label, keys, parameterDocumentation) }

let activeParameter: Int? =
if let activeParam: Int = signature[keys.activeParameter] {
Expand All @@ -88,13 +108,6 @@ fileprivate extension SignatureInformation {
nil
}

let documentation: StringOrMarkupContent? =
if let docComment: String = signature[keys.docComment] {
.markupContent(MarkupContent(kind: .markdown, value: docComment))
} else {
nil
}

self.init(
label: label,
documentation: documentation,
Expand Down
Loading