From 0ac09ca7f17450ce8ab0be43f07b0e6c56b119cb Mon Sep 17 00:00:00 2001 From: Ahmed Mahmoud Date: Sat, 13 Sep 2025 16:31:46 +0300 Subject: [PATCH 1/7] Separate signature and parameter documentation using swift-markdown --- Package.swift | 1 + Sources/SwiftLanguageService/CMakeLists.txt | 2 + .../ParametersDocumentationExtractor.swift | 192 +++++++++++++ .../SwiftLanguageService/SignatureHelp.swift | 35 ++- ...arametersDocumentationExtractorTests.swift | 252 ++++++++++++++++++ .../SwiftSignatureHelpTests.swift | 59 ++-- 6 files changed, 507 insertions(+), 34 deletions(-) create mode 100644 Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift create mode 100644 Tests/SourceKitLSPTests/ParametersDocumentationExtractorTests.swift diff --git a/Package.swift b/Package.swift index 563d6c4e1..a9afee1ce 100644 --- a/Package.swift +++ b/Package.swift @@ -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"), ] diff --git a/Sources/SwiftLanguageService/CMakeLists.txt b/Sources/SwiftLanguageService/CMakeLists.txt index 5d8f66ba1..d1b3fef79 100644 --- a/Sources/SwiftLanguageService/CMakeLists.txt +++ b/Sources/SwiftLanguageService/CMakeLists.txt @@ -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}) @@ -57,6 +58,7 @@ target_link_libraries(SwiftLanguageService PUBLIC SourceKitLSP ToolchainRegistry IndexStoreDB + Markdown SwiftSyntax::SwiftBasicFormat SwiftSyntax::SwiftSyntax ) diff --git a/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift b/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift new file mode 100644 index 000000000..c863bccc1 --- /dev/null +++ b/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift @@ -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 { + 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[.. (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 + } + + 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) +} diff --git a/Sources/SwiftLanguageService/SignatureHelp.swift b/Sources/SwiftLanguageService/SignatureHelp.swift index 1c74da66f..f5d59c839 100644 --- a/Sources/SwiftLanguageService/SignatureHelp.swift +++ b/Sources/SwiftLanguageService/SignatureHelp.swift @@ -36,7 +36,12 @@ 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 { @@ -44,8 +49,10 @@ fileprivate extension ParameterInformation { } 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 } @@ -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] { @@ -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, diff --git a/Tests/SourceKitLSPTests/ParametersDocumentationExtractorTests.swift b/Tests/SourceKitLSPTests/ParametersDocumentationExtractorTests.swift new file mode 100644 index 000000000..d4396ae94 --- /dev/null +++ b/Tests/SourceKitLSPTests/ParametersDocumentationExtractorTests.swift @@ -0,0 +1,252 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +import SKTestSupport +@_spi(Testing) import SwiftLanguageService +import XCTest + +final class ParametersDocumentationExtractorTests: XCTestCase { + func testParameterOutlineBasic() { + let comment = """ + This is a function that does something. + + - Parameters: + - name: The name parameter + - age: The age parameter + + This is additional documentation. + """ + + let (parameters, remaining) = extractParametersDocumentation(from: comment) + + XCTAssertEqual( + parameters, + [ + "name": "The name parameter", + "age": "The age parameter", + ] + ) + + XCTAssertEqual( + remaining, + """ + This is a function that does something. + + This is additional documentation. + """ + ) + } + + func testParameterOutlineWithComplexDescriptions() { + let comment = """ + Function documentation. + + - Parameters: + - callback: A closure that is called when the operation completes. + This can be `nil` if no callback is needed. + - timeout: The maximum time to wait in seconds. + Must be greater than 0. + + ```swift + let value = 5 + ``` + """ + + let (parameters, remaining) = extractParametersDocumentation(from: comment) + + XCTAssertEqual( + parameters, + [ + "callback": """ + A closure that is called when the operation completes. + This can be `nil` if no callback is needed. + """, + "timeout": """ + The maximum time to wait in seconds. + Must be greater than 0. + + ```swift + let value = 5 + ``` + """, + ] + ) + + XCTAssertEqual(remaining, "Function documentation.") + } + + func testParameterOutlineCaseInsensitive() { + let comment = """ + - PArAmEtERs: + - value: A test value + """ + + let (parameters, remaining) = extractParametersDocumentation(from: comment) + + XCTAssertEqual(parameters, ["value": "A test value"]) + XCTAssertTrue(remaining.isEmpty) + } + + func testSeparatedParametersBasic() { + let comment = """ + This function does something. + + - Parameter name: The name of the person + - Parameter age: The age of the person + + Additional documentation. + """ + + let (parameters, remaining) = extractParametersDocumentation(from: comment) + + XCTAssertEqual( + parameters, + [ + "name": "The name of the person", + "age": "The age of the person", + ] + ) + + XCTAssertEqual( + remaining, + """ + This function does something. + + Additional documentation. + """ + ) + } + + func testSeparatedParametersCaseInsensitive() { + let comment = """ + - parameter value: A test value + - PARAMETER count: A test count + """ + + let (parameters, remaining) = extractParametersDocumentation(from: comment) + + XCTAssertEqual( + parameters, + [ + "value": "A test value", + "count": "A test count", + ] + ) + + XCTAssertTrue(remaining.isEmpty) + } + + func testSeparatedParameterWithComplexDescription() { + let comment = """ + - Parameter completion: A completion handler that is called when the request finishes. + The handler receives a `Result` containing either the response data or an error. + This parameter can be `nil` if no completion handling is needed. + """ + + let (parameters, remaining) = extractParametersDocumentation(from: comment) + + XCTAssertEqual( + parameters, + [ + "completion": """ + A completion handler that is called when the request finishes. + The handler receives a `Result` containing either the response data or an error. + This parameter can be `nil` if no completion handling is needed. + """ + ] + ) + + XCTAssertTrue(remaining.isEmpty) + } + + func testDoxygenParameterBasic() { + let comment = #""" + This function processes data. + + \param input The input data to process + \param options Configuration options + + \returns The processed result + """# + + let (parameters, remaining) = extractParametersDocumentation(from: comment) + + XCTAssertEqual( + parameters, + [ + "input": "The input data to process", + "options": "Configuration options", + ] + ) + + XCTAssertEqual( + remaining, + #""" + This function processes data. + + \returns The processed result + """# + ) + } + + func testMarkdownWithoutParameters() { + let comment = """ + This is a function that takes no parameters. + + It does something useful and returns a value. + + - Returns: The processed result. + """ + + let (parameters, remaining) = extractParametersDocumentation(from: comment) + + XCTAssertEqual(parameters, [:]) + XCTAssertEqual( + remaining, + """ + This is a function that takes no parameters. + + It does something useful and returns a value. + + - Returns: The processed result. + """ + ) + } + + func testParameterExtractionDoesNotAffectOtherLists() { + let comment = """ + This function has various lists: + + - Parameters: + - name: The user name + - Returns: The processed result. + - Throws: An error if the function fails. + - Precondition: the user is logged in. + """ + + let (parameters, remaining) = extractParametersDocumentation(from: comment) + + XCTAssertEqual(parameters, ["name": "The user name"]) + + XCTAssertEqual( + remaining, + """ + This function has various lists: + + - Returns: The processed result. + - Throws: An error if the function fails. + - Precondition: the user is logged in. + """ + ) + } +} diff --git a/Tests/SourceKitLSPTests/SwiftSignatureHelpTests.swift b/Tests/SourceKitLSPTests/SwiftSignatureHelpTests.swift index bcfd40395..56ce13af9 100644 --- a/Tests/SourceKitLSPTests/SwiftSignatureHelpTests.swift +++ b/Tests/SourceKitLSPTests/SwiftSignatureHelpTests.swift @@ -59,9 +59,7 @@ final class SwiftSignatureHelpTests: XCTestCase { kind: .markdown, value: """ This is a test function - - Parameters: - - a: The first parameter - - b: The second parameter + - Returns: The result of the test """ ) @@ -70,8 +68,14 @@ final class SwiftSignatureHelpTests: XCTestCase { XCTAssertEqual( signature.parameters, [ - ParameterInformation(label: .offsets(start: 5, end: 11)), - ParameterInformation(label: .offsets(start: 13, end: 22)), + ParameterInformation( + label: .offsets(start: 5, end: 11), + documentation: .markupContent(MarkupContent(kind: .markdown, value: "The first parameter")), + ), + ParameterInformation( + label: .offsets(start: 13, end: 22), + documentation: .markupContent(MarkupContent(kind: .markdown, value: "The second parameter")), + ), ] ) } @@ -120,9 +124,7 @@ final class SwiftSignatureHelpTests: XCTestCase { kind: .markdown, value: """ Returns the element at the given row and column - - Parameters: - - row: The row index - - column: The column index + - Returns: The element at the given row and column """ ) @@ -131,8 +133,14 @@ final class SwiftSignatureHelpTests: XCTestCase { XCTAssertEqual( signature.parameters, [ - ParameterInformation(label: .offsets(start: 10, end: 18)), - ParameterInformation(label: .offsets(start: 20, end: 31)), + ParameterInformation( + label: .offsets(start: 10, end: 18), + documentation: .markupContent(MarkupContent(kind: .markdown, value: "The row index")), + ), + ParameterInformation( + label: .offsets(start: 20, end: 31), + documentation: .markupContent(MarkupContent(kind: .markdown, value: "The column index")), + ), ] ) } @@ -235,20 +243,21 @@ final class SwiftSignatureHelpTests: XCTestCase { .markupContent( MarkupContent( kind: .markdown, - value: """ - The label as an offset within the signature label - - Parameters: - - start: The start offset - - end: The end offset - """ + value: "The label as an offset within the signature label" ) ) ) XCTAssertEqual( signature.parameters, [ - ParameterInformation(label: .offsets(start: 7, end: 17)), - ParameterInformation(label: .offsets(start: 19, end: 27)), + ParameterInformation( + label: .offsets(start: 7, end: 17), + documentation: .markupContent(MarkupContent(kind: .markdown, value: "The start offset")) + ), + ParameterInformation( + label: .offsets(start: 19, end: 27), + documentation: .markupContent(MarkupContent(kind: .markdown, value: "The end offset")) + ), ] ) } @@ -639,9 +648,7 @@ final class SwiftSignatureHelpTests: XCTestCase { kind: .markdown, value: """ A utility function that combines values - - Parameters: - - first: The first value - - second: The second value + - Returns: The combined result """ ) @@ -650,8 +657,14 @@ final class SwiftSignatureHelpTests: XCTestCase { XCTAssertEqual( signature.parameters, [ - ParameterInformation(label: .offsets(start: 8, end: 21)), - ParameterInformation(label: .offsets(start: 23, end: 34)), + ParameterInformation( + label: .offsets(start: 8, end: 21), + documentation: .markupContent(MarkupContent(kind: .markdown, value: "The first value")) + ), + ParameterInformation( + label: .offsets(start: 23, end: 34), + documentation: .markupContent(MarkupContent(kind: .markdown, value: "The second value")) + ), ] ) } From beb9e0ce15950f474c0c01780323f0d2a8b36c20 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmoud Date: Mon, 15 Sep 2025 21:36:20 +0300 Subject: [PATCH 2/7] Correctly add swift-markdown in cmake configuration --- CMakeLists.txt | 1 + Sources/SwiftLanguageService/CMakeLists.txt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index eeca45548..c7ad7e943 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,7 @@ find_package(SwiftCollections QUIET) find_package(SwiftSyntax CONFIG REQUIRED) find_package(SwiftASN1 CONFIG REQUIRED) find_package(SwiftCrypto CONFIG REQUIRED) +find_package(SwiftMarkdown CONFIG REQUIRED) include(SwiftSupport) diff --git a/Sources/SwiftLanguageService/CMakeLists.txt b/Sources/SwiftLanguageService/CMakeLists.txt index d1b3fef79..cb257a595 100644 --- a/Sources/SwiftLanguageService/CMakeLists.txt +++ b/Sources/SwiftLanguageService/CMakeLists.txt @@ -58,7 +58,7 @@ target_link_libraries(SwiftLanguageService PUBLIC SourceKitLSP ToolchainRegistry IndexStoreDB - Markdown + SwiftMarkdown::Markdown SwiftSyntax::SwiftBasicFormat SwiftSyntax::SwiftSyntax ) From 7a1d592dcf709944392af2154c2d691af4610744 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmoud Date: Mon, 15 Sep 2025 21:57:07 +0300 Subject: [PATCH 3/7] Refine ParametersDocumentationExtractor to avoid mutations --- .../ParametersDocumentationExtractor.swift | 127 +++++++++++------- 1 file changed, 81 insertions(+), 46 deletions(-) diff --git a/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift b/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift index c863bccc1..afe786020 100644 --- a/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift +++ b/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift @@ -1,25 +1,69 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + import Foundation import Markdown +/// Extracts parameter documentation from a markdown string. +/// +/// The parameter extraction implementation is almost ported from the implementation in the Swift compiler codebase. +/// +/// The problem with doing that in the Swift compiler codebase is that once you parse a the comment as markdown into +/// a `Document` you cannot easily convert it back into markdown (we'd need to write our own markdown formatter). +/// Besides, `cmark` doesn't handle Doxygen commands. +/// +/// We considered using `swift-docc` but we faced some problems with it: +/// +/// 1. We would need to refactor existing use of `swift-docc` in SourceKit-LSP to reuse some of that logic here besides +/// providing the required arguments. +/// 2. The result returned by DocC can't be directly converted to markdown, we'd need to provide our own DocC markdown renderer. +/// +/// Implementing this using `swift-markdown` allows us to easily parse the comment, process it, convert it back to markdown. +/// It also provides minimal parsing for Doxygen commands (we're only interested in `\param`) allowing us to use the same +/// implementation for Clang-based declarations. +/// +/// Although this approach involves code duplication, it's simple enough for the initial implementation. We should consider +/// `swift-docc` in the future. private struct ParametersDocumentationExtractor { - private var parameters = [String: String]() + struct Parameter { + let name: String + let documentation: 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) { + func extract(from markdown: String) -> (parameters: [String: String], remaining: String) { let document = Document(parsing: markdown, options: [.parseBlockDirectives, .parseMinimalDoxygen]) - var remainingBlocks = [any BlockMarkup]() + var parameters: [String: String] = [:] + var remainingBlocks: [any BlockMarkup] = [] for block in document.blockChildren { switch block { case let unorderedList as UnorderedList: - if let newUnorderedList = extract(from: unorderedList) { + let (newUnorderedList, params) = extract(from: unorderedList) + if let newUnorderedList { remainingBlocks.append(newUnorderedList) } + + for param in params { + parameters[param.name] = param.documentation + } + case let doxygenParameter as DoxygenParameter: - extract(from: doxygenParameter) + let param = extract(from: doxygenParameter) + parameters[param.name] = param.documentation + default: remainingBlocks.append(block) } @@ -31,29 +75,35 @@ private struct ParametersDocumentationExtractor { } /// Extracts parameter documentation from a Doxygen parameter command. - private mutating func extract(from doxygenParameter: DoxygenParameter) { - parameters[doxygenParameter.name] = Document(doxygenParameter.blockChildren).format() + private func extract(from doxygenParameter: DoxygenParameter) -> Parameter { + return Parameter( + name: doxygenParameter.name, + documentation: 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]() + private func extract(from unorderedList: UnorderedList) -> (remaining: UnorderedList?, parameters: [Parameter]) { + var parameters: [Parameter] = [] + var newItems: [ListItem] = [] for item in unorderedList.listItems { - if extractSingle(from: item) || extractOutline(from: item) { - continue + if let param = extractSingle(from: item) { + parameters.append(param) + } else if let params = extractOutline(from: item) { + parameters.append(contentsOf: params) + } else { + newItems.append(item) } - - newItems.append(item) } if newItems.isEmpty { - return nil + return (remaining: nil, parameters: parameters) } - return UnorderedList(newItems) + return (remaining: UnorderedList(newItems), parameters: parameters) } /// Parameter documentation from a `Parameters:` outline. @@ -65,33 +115,24 @@ private struct ParametersDocumentationExtractor { /// ``` /// /// - Returns: True if the list item has parameter outline documentation, false otherwise. - private mutating func extractOutline(from listItem: ListItem) -> Bool { + private func extractOutline(from listItem: ListItem) -> [Parameter]? { guard let firstChild = listItem.child(at: 0) as? Paragraph, let headingText = firstChild.child(at: 0) as? Text else { - return false + return nil } - let parametersPrefix = "parameters:" - let headingContent = headingText.string.trimmingCharacters(in: .whitespaces) - - guard headingContent.lowercased().hasPrefix(parametersPrefix) else { - return false + guard headingText.string.trimmingCharacters(in: .whitespaces).lowercased().hasPrefix("parameters:") else { + return nil } - for child in listItem.children { + return listItem.children.flatMap { child in guard let nestedList = child as? UnorderedList else { - continue + return [] as [Parameter] } - for nestedItem in nestedList.listItems { - if let parameter = extractOutlineItem(from: nestedItem) { - parameters[parameter.name] = parameter.documentation - } - } + return nestedList.listItems.compactMap(extractOutlineItem) } - - return true } /// Extracts parameter documentation from a single parameter. @@ -102,40 +143,34 @@ private struct ParametersDocumentationExtractor { /// ``` /// /// - Returns: True if the list item has single parameter documentation, false otherwise. - private mutating func extractSingle(from listItem: ListItem) -> Bool { + private func extractSingle(from listItem: ListItem) -> Parameter? { guard let paragraph = listItem.child(at: 0) as? Paragraph, let paragraphText = paragraph.child(at: 0) as? Text else { - return false + return nil } let parameterPrefix = "parameter " let paragraphContent = paragraphText.string guard paragraphContent.count >= parameterPrefix.count else { - return false + return nil } let prefixEnd = paragraphContent.index(paragraphContent.startIndex, offsetBy: parameterPrefix.count) let potentialMatch = paragraphContent[.. (name: String, documentation: String)? { + private func extractOutlineItem(from listItem: ListItem) -> Parameter? { guard let paragraph = listItem.child(at: 0) as? Paragraph else { return nil } @@ -157,7 +192,7 @@ private struct ParametersDocumentationExtractor { private func extractParam( firstTextContent: String, listItem: ListItem - ) -> (name: String, documentation: String)? { + ) -> Parameter? { guard let paragraph = listItem.child(at: 0) as? Paragraph else { return nil } @@ -178,7 +213,7 @@ private struct ParametersDocumentationExtractor { let remainingChildren = [Paragraph(remainingParagraphChildren)] + listItem.blockChildren.dropFirst() let documentation = Document(remainingChildren).format() - return (name, documentation) + return Parameter(name: name, documentation: documentation) } } @@ -187,6 +222,6 @@ private struct ParametersDocumentationExtractor { /// - 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() + let extractor = ParametersDocumentationExtractor() return extractor.extract(from: markdown) } From 63e9704bb7d2dc544f9ff3f1bdbac6953afc3ced Mon Sep 17 00:00:00 2001 From: Ahmed Mahmoud Date: Mon, 15 Sep 2025 23:42:52 +0300 Subject: [PATCH 4/7] Test parameter extraction edge cases and match swift-docc --- .../ParametersDocumentationExtractor.swift | 6 +- .../SwiftLanguageService/SignatureHelp.swift | 1 + ...arametersDocumentationExtractorTests.swift | 75 +++++++++++++++++++ .../SwiftSignatureHelpTests.swift | 73 ++++++++++++++++++ 4 files changed, 153 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift b/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift index afe786020..376b42def 100644 --- a/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift +++ b/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift @@ -57,12 +57,14 @@ private struct ParametersDocumentationExtractor { } for param in params { - parameters[param.name] = param.documentation + // If duplicate parameter documentation is found, keep the first one following swift-docc's behavior + parameters[param.name] = parameters[param.name] ?? param.documentation } case let doxygenParameter as DoxygenParameter: let param = extract(from: doxygenParameter) - parameters[param.name] = param.documentation + // If duplicate parameter documentation is found, keep the first one following swift-docc's behavior + parameters[param.name] = parameters[param.name] ?? param.documentation default: remainingBlocks.append(block) diff --git a/Sources/SwiftLanguageService/SignatureHelp.swift b/Sources/SwiftLanguageService/SignatureHelp.swift index f5d59c839..8939c628f 100644 --- a/Sources/SwiftLanguageService/SignatureHelp.swift +++ b/Sources/SwiftLanguageService/SignatureHelp.swift @@ -36,6 +36,7 @@ fileprivate extension String { } fileprivate extension ParameterInformation { + /// - Parameter parameterDocumentation: A dictionary with the parameter name as the key and its documentation as the value. init?( _ parameter: SKDResponseDictionary, _ signatureLabel: String, diff --git a/Tests/SourceKitLSPTests/ParametersDocumentationExtractorTests.swift b/Tests/SourceKitLSPTests/ParametersDocumentationExtractorTests.swift index d4396ae94..ab3170adc 100644 --- a/Tests/SourceKitLSPTests/ParametersDocumentationExtractorTests.swift +++ b/Tests/SourceKitLSPTests/ParametersDocumentationExtractorTests.swift @@ -249,4 +249,79 @@ final class ParametersDocumentationExtractorTests: XCTestCase { """ ) } + + /// Tests that we drop non-parameter items in the parameter outline. Aligns with swift-docc. + func testDropsNonParameterItemsInParameterOutline() { + let comment = """ + - Parameters: + - number: The number to do stuff with + - TODO Improve this documentation + """ + + let (parameters, remaining) = extractParametersDocumentation(from: comment) + + XCTAssertEqual(parameters, ["number": "The number to do stuff with"]) + XCTAssertTrue(remaining.isEmpty) + } + + /// Tests that we drop duplicate parameter documentation and keep thefirst one. Aligns with swift-docc. + func testDropsDuplicateParameterDocumentation() { + let comment = """ + - Parameters: + - number: The number to do stuff with + - number: The number to do amazing stuff with + """ + + let (parameters, remaining) = extractParametersDocumentation(from: comment) + + XCTAssertEqual(parameters, ["number": "The number to do stuff with"]) + XCTAssertTrue(remaining.isEmpty) + } + + /// Tests that we drop text after the colon in the parameter outline. Aligns with swift-docc. + func testDropsTextAfterColonInParameterOutline() { + let comment = """ + - Parameters: listing parameter documentation below + - number: The number to do stuff + """ + + let (parameters, remaining) = extractParametersDocumentation(from: comment) + + XCTAssertEqual(parameters, ["number": "The number to do stuff"]) + XCTAssertTrue(remaining.isEmpty) + } + + /// Tests that we support mixed parameter documentation styles in a single comment. Aligns with swift-docc. + func testMixedParameterDocumentationStyles() { + let comment = #""" + Function documentation. + + - Parameters: + - first: First parameter from Parameters section + - Parameter second: Second parameter from separate Parameter + \param third Third parameter from Doxygen style + + Additional documentation. + """# + + let (parameters, remaining) = extractParametersDocumentation(from: comment) + + XCTAssertEqual( + parameters, + [ + "first": "First parameter from Parameters section", + "second": "Second parameter from separate Parameter", + "third": "Third parameter from Doxygen style", + ] + ) + + XCTAssertEqual( + remaining, + """ + Function documentation. + + Additional documentation. + """ + ) + } } diff --git a/Tests/SourceKitLSPTests/SwiftSignatureHelpTests.swift b/Tests/SourceKitLSPTests/SwiftSignatureHelpTests.swift index 56ce13af9..ed77b26b5 100644 --- a/Tests/SourceKitLSPTests/SwiftSignatureHelpTests.swift +++ b/Tests/SourceKitLSPTests/SwiftSignatureHelpTests.swift @@ -668,4 +668,77 @@ final class SwiftSignatureHelpTests: XCTestCase { ] ) } + + func testSignatureHelpMatchesParametersWithInternalNames() async throws { + try await SkipUnless.sourcekitdSupportsSignatureHelp() + + let testClient = try await TestSourceKitLSPClient() + let uri = DocumentURI(for: .swift) + + let positions = testClient.openDocument( + """ + /// - Parameter number: The number to add 1 to + func addOne(to number: Int) -> Int { number + 1 } + + func main() { + addOne(1️⃣) + } + """, + uri: uri + ) + + let result = try await testClient.send( + SignatureHelpRequest( + textDocument: TextDocumentIdentifier(uri), + position: positions["1️⃣"] + ) + ) + + let signatureHelp = try XCTUnwrap(result) + let signature = try XCTUnwrap(signatureHelp.signatures.only) + let signatureDocumentation = try XCTUnwrap(signature.documentation) + let parameter = try XCTUnwrap(signature.parameters?.only) + + XCTAssertEqual(signatureDocumentation, .markupContent(MarkupContent(kind: .markdown, value: ""))) + XCTAssertEqual( + parameter.documentation, + .markupContent(MarkupContent(kind: .markdown, value: "The number to add 1 to")) + ) + } + + /// Tests that we drop parameter documentation for parameters that don't exist aligning with swift-docc. + func testSignatureHelpDropsNonExistentParameterDocumentation() async throws { + try await SkipUnless.sourcekitdSupportsSignatureHelp() + + let testClient = try await TestSourceKitLSPClient() + let uri = DocumentURI(for: .swift) + + let positions = testClient.openDocument( + """ + /// - Parameters: + /// - numberWithTypo: The number to do stuff with + func compute(number: Int) {} + + func main() { + compute(1️⃣) + } + """, + uri: uri + ) + + let result = try await testClient.send( + SignatureHelpRequest( + textDocument: TextDocumentIdentifier(uri), + position: positions["1️⃣"] + ) + ) + + let signatureHelp = try XCTUnwrap(result) + let signature = try XCTUnwrap(signatureHelp.signatures.only) + let parameter = try XCTUnwrap(signature.parameters?.only) + let signatureDocumentation = try XCTUnwrap(signature.documentation) + + XCTAssertEqual(signatureDocumentation, .markupContent(MarkupContent(kind: .markdown, value: ""))) + XCTAssertNil(parameter.documentation) + } } From 83e1522e63a11cba05b357a1a1ad29582f6e8af0 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmoud Date: Wed, 17 Sep 2025 23:09:40 +0300 Subject: [PATCH 5/7] Extract parameter documentation with raw identifiers --- .../ParametersDocumentationExtractor.swift | 52 ++++++++++++++++--- ...arametersDocumentationExtractorTests.swift | 23 ++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift b/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift index 376b42def..9ef5e5c22 100644 --- a/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift +++ b/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift @@ -168,7 +168,7 @@ private struct ParametersDocumentationExtractor { let remainingContent = String(paragraphContent[prefixEnd...]).trimmingCharacters(in: .whitespaces) - return extractParam(firstTextContent: remainingContent, listItem: listItem) + return extractParam(firstTextContent: remainingContent, listItem: listItem, single: true) } /// Extracts a parameter field from a list item (used for parameter outline items) @@ -177,11 +177,9 @@ private struct ParametersDocumentationExtractor { return nil } - guard let paragraphText = paragraph.child(at: 0) as? Text else { - return nil - } + let firstText = paragraph.child(at: 0) as? Text - return extractParam(firstTextContent: paragraphText.string, listItem: listItem) + return extractParam(firstTextContent: firstText?.string ?? "", listItem: listItem, single: false) } /// Extracts a parameter field from a list item provided the relevant first text content allowing reuse in ``extractOutlineItem`` and ``extractSingle`` @@ -189,11 +187,13 @@ private struct ParametersDocumentationExtractor { /// - 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 + /// - single: Whether the parameter is a single parameter or part of a parameter outline /// /// - Returns: A tuple containing the parameter name and documentation if a parameter was found, nil otherwise. private func extractParam( firstTextContent: String, - listItem: ListItem + listItem: ListItem, + single: Bool ) -> Parameter? { guard let paragraph = listItem.child(at: 0) as? Paragraph else { return nil @@ -202,7 +202,7 @@ private struct ParametersDocumentationExtractor { let components = firstTextContent.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) guard components.count == 2 else { - return nil + return extractWithRawIdentifier(from: listItem, single: single) } let name = String(components[0]).trimmingCharacters(in: .whitespaces) @@ -217,6 +217,44 @@ private struct ParametersDocumentationExtractor { return Parameter(name: name, documentation: documentation) } + + /// Extracts a parameter with its name as a raw identifier. + /// + /// Example: + /// ```markdown + /// - Parameter `foo bar`: documentation + /// - Parameters: + /// - `foo bar`: documentation + /// ``` + /// + /// - Parameters: + /// - listItem: The list item to extract the parameter from + /// - single: Whether the parameter is a single parameter or part of a parameter outline + func extractWithRawIdentifier(from listItem: ListItem, single: Bool) -> Parameter? { + /// The index of ``InlineCode`` for the raw identifier parameter name in the first paragraph of ``listItem`` + let inlineCodeIndex = single ? 1 : 0 + + guard let paragraph = listItem.child(at: 0) as? Paragraph, + let rawIdentifier = paragraph.child(at: inlineCodeIndex) as? InlineCode, + let text = paragraph.child(at: inlineCodeIndex + 1) as? Text + else { + return nil + } + + let textContent = text.string.trimmingCharacters(in: .whitespaces) + + guard textContent.hasPrefix(":") else { + return nil + } + + let remainingTextContent = String(textContent.dropFirst()).trimmingCharacters(in: .whitespaces) + let remainingParagraphChildren = + [Text(remainingTextContent)] + paragraph.inlineChildren.dropFirst(inlineCodeIndex + 2) + let remainingChildren = [Paragraph(remainingParagraphChildren)] + listItem.blockChildren.dropFirst(1) + let documentation = Document(remainingChildren).format() + + return Parameter(name: rawIdentifier.code, documentation: documentation) + } } /// Extracts parameter documentation from markdown text. diff --git a/Tests/SourceKitLSPTests/ParametersDocumentationExtractorTests.swift b/Tests/SourceKitLSPTests/ParametersDocumentationExtractorTests.swift index ab3170adc..5de0e8c31 100644 --- a/Tests/SourceKitLSPTests/ParametersDocumentationExtractorTests.swift +++ b/Tests/SourceKitLSPTests/ParametersDocumentationExtractorTests.swift @@ -324,4 +324,27 @@ final class ParametersDocumentationExtractorTests: XCTestCase { """ ) } + + func testSeparatedParameterWithRawIdentifier() { + let comment = """ + - Parameter `foo: bar :) `: hello + """ + + let (parameters, remaining) = extractParametersDocumentation(from: comment) + + XCTAssertEqual(parameters, ["foo: bar :) ": "hello"]) + XCTAssertTrue(remaining.isEmpty) + } + + func testParameterOutlineWithRawIdentifier() { + let comment = """ + - Parameters: + - `foo: bar :) `: hello + """ + + let (parameters, remaining) = extractParametersDocumentation(from: comment) + + XCTAssertEqual(parameters, ["foo: bar :) ": "hello"]) + XCTAssertTrue(remaining.isEmpty) + } } From 668da582d06a4dc3d1dc8bca9c8f0fac209007f1 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmoud Date: Sat, 20 Sep 2025 23:16:04 +0300 Subject: [PATCH 6/7] Make ParametersDocumentationExtractor members top-level & remove firstTextContent parameter --- Sources/SwiftLanguageService/CMakeLists.txt | 2 +- .../ExtractParametersDocumentation.swift | 259 +++++++++++++++++ .../ParametersDocumentationExtractor.swift | 267 ------------------ ...ExtractParametersDocumentationTests.swift} | 6 +- 4 files changed, 263 insertions(+), 271 deletions(-) create mode 100644 Sources/SwiftLanguageService/ExtractParametersDocumentation.swift delete mode 100644 Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift rename Tests/SourceKitLSPTests/{ParametersDocumentationExtractorTests.swift => ExtractParametersDocumentationTests.swift} (98%) diff --git a/Sources/SwiftLanguageService/CMakeLists.txt b/Sources/SwiftLanguageService/CMakeLists.txt index cb257a595..ef3635824 100644 --- a/Sources/SwiftLanguageService/CMakeLists.txt +++ b/Sources/SwiftLanguageService/CMakeLists.txt @@ -46,7 +46,7 @@ add_library(SwiftLanguageService STATIC GeneratedInterfaceManager.swift SignatureHelp.swift AdjustPositionToStartOfArgument.swift - ParametersDocumentationExtractor.swift + ExtractParametersDocumentation.swift ) set_target_properties(SwiftLanguageService PROPERTIES INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY}) diff --git a/Sources/SwiftLanguageService/ExtractParametersDocumentation.swift b/Sources/SwiftLanguageService/ExtractParametersDocumentation.swift new file mode 100644 index 000000000..91a4c8b47 --- /dev/null +++ b/Sources/SwiftLanguageService/ExtractParametersDocumentation.swift @@ -0,0 +1,259 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +import Markdown + +private struct Parameter { + let name: String + let documentation: String +} + +/// Extracts parameter documentation from a Doxygen parameter command. +private func extractParameter(from doxygenParameter: DoxygenParameter) -> Parameter { + return Parameter( + name: doxygenParameter.name, + documentation: 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 func extractParameters( + from unorderedList: UnorderedList +) -> (remaining: UnorderedList?, parameters: [Parameter]) { + var parameters: [Parameter] = [] + var newItems: [ListItem] = [] + + for item in unorderedList.listItems { + if let param = extractSingleParameter(from: item) { + parameters.append(param) + } else if let params = extractParametersOutline(from: item) { + parameters.append(contentsOf: params) + } else { + newItems.append(item) + } + } + + if newItems.isEmpty { + return (remaining: nil, parameters: parameters) + } + + return (remaining: UnorderedList(newItems), parameters: parameters) +} + +/// Parameter documentation from a `Parameters:` outline. +/// +/// Example: +/// ```markdown +/// - Parameters: +/// - param: description +/// ``` +/// +/// - Returns: The extracted parameters if any, nil otherwise. +private func extractParametersOutline(from listItem: ListItem) -> [Parameter]? { + guard + let firstChild = listItem.child(at: 0) as? Paragraph, + let headingText = firstChild.child(at: 0) as? Text, + headingText.string.trimmingCharacters(in: .whitespaces).lowercased().hasPrefix("parameters:") + else { + return nil + } + + return listItem.children.flatMap { child in + guard let nestedList = child as? UnorderedList else { + return [] as [Parameter] + } + + return nestedList.listItems.compactMap { extractParameter(listItem: $0) } + } +} + +/// Extracts parameter documentation from a single parameter. +/// +/// Example: +/// ```markdown +/// - Parameter param: description +/// ``` +/// +/// - Returns: The extracted parameter if any, nil otherwise. +private func extractSingleParameter(from listItem: ListItem) -> Parameter? { + guard let paragraph = listItem.child(at: 0) as? Paragraph, + let paragraphText = paragraph.child(at: 0) as? Text + else { + return nil + } + + let parameterPrefix = "parameter " + let paragraphContent = paragraphText.string + + guard + let prefixEnd = paragraphContent.index( + paragraphContent.startIndex, + offsetBy: parameterPrefix.count, + limitedBy: paragraphContent.endIndex + ) + else { + return nil + } + + let potentialMatch = paragraphContent[.. Parameter? { + guard let paragraph = listItem.child(at: 0) as? Paragraph else { + return nil + } + + guard let firstText = paragraph.child(at: 0) as? Text else { + return extractParameterWithRawIdentifier(from: listItem) + } + + let components = firstText.string.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + + guard components.count == 2 else { + return extractParameterWithRawIdentifier(from: listItem) + } + + let name = String(components[0]).trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { + return nil + } + + 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 Parameter(name: name, documentation: documentation) +} + +/// Extracts a parameter with its name as a raw identifier. +/// +/// Example: +/// ```markdown +/// - Parameter `foo bar`: documentation +/// - Parameters: +/// - `foo bar`: documentation +/// ``` +/// +/// - Parameters: +/// - listItem: The list item to extract the parameter from +private func extractParameterWithRawIdentifier(from listItem: ListItem) -> Parameter? { + guard let paragraph = listItem.child(at: 0) as? Paragraph, + let rawIdentifier = paragraph.child(at: 0) as? InlineCode, + let text = paragraph.child(at: 1) as? Text + else { + return nil + } + + let textContent = text.string.trimmingCharacters(in: .whitespaces) + + guard textContent.hasPrefix(":") else { + return nil + } + + let remainingTextContent = String(textContent.dropFirst()).trimmingCharacters(in: .whitespaces) + let remainingParagraphChildren = + [Text(remainingTextContent)] + paragraph.inlineChildren.dropFirst(2) + let remainingChildren = [Paragraph(remainingParagraphChildren)] + listItem.blockChildren.dropFirst(1) + let documentation = Document(remainingChildren).format() + + return Parameter(name: rawIdentifier.code, documentation: documentation) +} + +/// Extracts parameter documentation from markdown text. +/// +/// The parameter extraction implementation is almost ported from the implementation in the Swift compiler codebase. +/// +/// The problem with doing that in the Swift compiler codebase is that once you parse a the comment as markdown into +/// a `Document` you cannot easily convert it back into markdown (we'd need to write our own markdown formatter). +/// Besides, `cmark` doesn't handle Doxygen commands. +/// +/// We considered using `swift-docc` but we faced some problems with it: +/// +/// 1. We would need to refactor existing use of `swift-docc` in SourceKit-LSP to reuse some of that logic here besides +/// providing the required arguments. +/// 2. The result returned by DocC can't be directly converted to markdown, we'd need to provide our own DocC markdown renderer. +/// +/// Implementing this using `swift-markdown` allows us to easily parse the comment, process it, convert it back to markdown. +/// It also provides minimal parsing for Doxygen commands (we're only interested in `\param`) allowing us to use the same +/// implementation for Clang-based declarations. +/// +/// Although this approach involves code duplication, it's simple enough for the initial implementation. We should consider +/// `swift-docc` in the future. +/// +/// - 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 +) -> (parameters: [String: String], remaining: String) { + let document = Document(parsing: markdown, options: [.parseBlockDirectives, .parseMinimalDoxygen]) + + var parameters: [String: String] = [:] + var remainingBlocks: [any BlockMarkup] = [] + + for block in document.blockChildren { + switch block { + case let unorderedList as UnorderedList: + let (newUnorderedList, params) = extractParameters(from: unorderedList) + if let newUnorderedList { + remainingBlocks.append(newUnorderedList) + } + + for param in params { + // If duplicate parameter documentation is found, keep the first one following swift-docc's behavior + parameters[param.name] = parameters[param.name] ?? param.documentation + } + + case let doxygenParameter as DoxygenParameter: + let param = extractParameter(from: doxygenParameter) + // If duplicate parameter documentation is found, keep the first one following swift-docc's behavior + parameters[param.name] = parameters[param.name] ?? param.documentation + + default: + remainingBlocks.append(block) + } + } + + let remaining = Document(remainingBlocks).format() + + return (parameters: parameters, remaining: remaining) +} diff --git a/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift b/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift deleted file mode 100644 index 9ef5e5c22..000000000 --- a/Sources/SwiftLanguageService/ParametersDocumentationExtractor.swift +++ /dev/null @@ -1,267 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import Foundation -import Markdown - -/// Extracts parameter documentation from a markdown string. -/// -/// The parameter extraction implementation is almost ported from the implementation in the Swift compiler codebase. -/// -/// The problem with doing that in the Swift compiler codebase is that once you parse a the comment as markdown into -/// a `Document` you cannot easily convert it back into markdown (we'd need to write our own markdown formatter). -/// Besides, `cmark` doesn't handle Doxygen commands. -/// -/// We considered using `swift-docc` but we faced some problems with it: -/// -/// 1. We would need to refactor existing use of `swift-docc` in SourceKit-LSP to reuse some of that logic here besides -/// providing the required arguments. -/// 2. The result returned by DocC can't be directly converted to markdown, we'd need to provide our own DocC markdown renderer. -/// -/// Implementing this using `swift-markdown` allows us to easily parse the comment, process it, convert it back to markdown. -/// It also provides minimal parsing for Doxygen commands (we're only interested in `\param`) allowing us to use the same -/// implementation for Clang-based declarations. -/// -/// Although this approach involves code duplication, it's simple enough for the initial implementation. We should consider -/// `swift-docc` in the future. -private struct ParametersDocumentationExtractor { - struct Parameter { - let name: String - let documentation: String - } - - /// Extracts parameter documentation from a markdown string. - /// - /// - Returns: A tuple containing the extracted parameters and the remaining markdown. - func extract(from markdown: String) -> (parameters: [String: String], remaining: String) { - let document = Document(parsing: markdown, options: [.parseBlockDirectives, .parseMinimalDoxygen]) - - var parameters: [String: String] = [:] - var remainingBlocks: [any BlockMarkup] = [] - - for block in document.blockChildren { - switch block { - case let unorderedList as UnorderedList: - let (newUnorderedList, params) = extract(from: unorderedList) - if let newUnorderedList { - remainingBlocks.append(newUnorderedList) - } - - for param in params { - // If duplicate parameter documentation is found, keep the first one following swift-docc's behavior - parameters[param.name] = parameters[param.name] ?? param.documentation - } - - case let doxygenParameter as DoxygenParameter: - let param = extract(from: doxygenParameter) - // If duplicate parameter documentation is found, keep the first one following swift-docc's behavior - parameters[param.name] = parameters[param.name] ?? param.documentation - - default: - remainingBlocks.append(block) - } - } - - let remaining = Document(remainingBlocks).format() - - return (parameters, remaining) - } - - /// Extracts parameter documentation from a Doxygen parameter command. - private func extract(from doxygenParameter: DoxygenParameter) -> Parameter { - return Parameter( - name: doxygenParameter.name, - documentation: 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 func extract(from unorderedList: UnorderedList) -> (remaining: UnorderedList?, parameters: [Parameter]) { - var parameters: [Parameter] = [] - var newItems: [ListItem] = [] - - for item in unorderedList.listItems { - if let param = extractSingle(from: item) { - parameters.append(param) - } else if let params = extractOutline(from: item) { - parameters.append(contentsOf: params) - } else { - newItems.append(item) - } - } - - if newItems.isEmpty { - return (remaining: nil, parameters: parameters) - } - - return (remaining: UnorderedList(newItems), parameters: parameters) - } - - /// Parameter documentation from a `Parameters:` outline. - /// - /// Example: - /// ```markdown - /// - Parameters: - /// - param: description - /// ``` - /// - /// - Returns: True if the list item has parameter outline documentation, false otherwise. - private func extractOutline(from listItem: ListItem) -> [Parameter]? { - guard let firstChild = listItem.child(at: 0) as? Paragraph, - let headingText = firstChild.child(at: 0) as? Text - else { - return nil - } - - guard headingText.string.trimmingCharacters(in: .whitespaces).lowercased().hasPrefix("parameters:") else { - return nil - } - - return listItem.children.flatMap { child in - guard let nestedList = child as? UnorderedList else { - return [] as [Parameter] - } - - return nestedList.listItems.compactMap(extractOutlineItem) - } - } - - /// 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 func extractSingle(from listItem: ListItem) -> Parameter? { - guard let paragraph = listItem.child(at: 0) as? Paragraph, - let paragraphText = paragraph.child(at: 0) as? Text - else { - return nil - } - - let parameterPrefix = "parameter " - let paragraphContent = paragraphText.string - - guard paragraphContent.count >= parameterPrefix.count else { - return nil - } - - let prefixEnd = paragraphContent.index(paragraphContent.startIndex, offsetBy: parameterPrefix.count) - let potentialMatch = paragraphContent[.. Parameter? { - guard let paragraph = listItem.child(at: 0) as? Paragraph else { - return nil - } - - let firstText = paragraph.child(at: 0) as? Text - - return extractParam(firstTextContent: firstText?.string ?? "", listItem: listItem, single: false) - } - - /// 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 - /// - single: Whether the parameter is a single parameter or part of a parameter outline - /// - /// - Returns: A tuple containing the parameter name and documentation if a parameter was found, nil otherwise. - private func extractParam( - firstTextContent: String, - listItem: ListItem, - single: Bool - ) -> Parameter? { - 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 extractWithRawIdentifier(from: listItem, single: single) - } - - let name = String(components[0]).trimmingCharacters(in: .whitespaces) - guard !name.isEmpty else { - return nil - } - - 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 Parameter(name: name, documentation: documentation) - } - - /// Extracts a parameter with its name as a raw identifier. - /// - /// Example: - /// ```markdown - /// - Parameter `foo bar`: documentation - /// - Parameters: - /// - `foo bar`: documentation - /// ``` - /// - /// - Parameters: - /// - listItem: The list item to extract the parameter from - /// - single: Whether the parameter is a single parameter or part of a parameter outline - func extractWithRawIdentifier(from listItem: ListItem, single: Bool) -> Parameter? { - /// The index of ``InlineCode`` for the raw identifier parameter name in the first paragraph of ``listItem`` - let inlineCodeIndex = single ? 1 : 0 - - guard let paragraph = listItem.child(at: 0) as? Paragraph, - let rawIdentifier = paragraph.child(at: inlineCodeIndex) as? InlineCode, - let text = paragraph.child(at: inlineCodeIndex + 1) as? Text - else { - return nil - } - - let textContent = text.string.trimmingCharacters(in: .whitespaces) - - guard textContent.hasPrefix(":") else { - return nil - } - - let remainingTextContent = String(textContent.dropFirst()).trimmingCharacters(in: .whitespaces) - let remainingParagraphChildren = - [Text(remainingTextContent)] + paragraph.inlineChildren.dropFirst(inlineCodeIndex + 2) - let remainingChildren = [Paragraph(remainingParagraphChildren)] + listItem.blockChildren.dropFirst(1) - let documentation = Document(remainingChildren).format() - - return Parameter(name: rawIdentifier.code, documentation: 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) { - let extractor = ParametersDocumentationExtractor() - return extractor.extract(from: markdown) -} diff --git a/Tests/SourceKitLSPTests/ParametersDocumentationExtractorTests.swift b/Tests/SourceKitLSPTests/ExtractParametersDocumentationTests.swift similarity index 98% rename from Tests/SourceKitLSPTests/ParametersDocumentationExtractorTests.swift rename to Tests/SourceKitLSPTests/ExtractParametersDocumentationTests.swift index 5de0e8c31..93bd664bd 100644 --- a/Tests/SourceKitLSPTests/ParametersDocumentationExtractorTests.swift +++ b/Tests/SourceKitLSPTests/ExtractParametersDocumentationTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Copyright (c) 2025 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -15,7 +15,7 @@ import SKTestSupport @_spi(Testing) import SwiftLanguageService import XCTest -final class ParametersDocumentationExtractorTests: XCTestCase { +final class ExtractParametersDocumentationTests: XCTestCase { func testParameterOutlineBasic() { let comment = """ This is a function that does something. @@ -264,7 +264,7 @@ final class ParametersDocumentationExtractorTests: XCTestCase { XCTAssertTrue(remaining.isEmpty) } - /// Tests that we drop duplicate parameter documentation and keep thefirst one. Aligns with swift-docc. + /// Tests that we drop duplicate parameter documentation and keep the first one. Aligns with swift-docc. func testDropsDuplicateParameterDocumentation() { let comment = """ - Parameters: From 992bedc7f2b7a743b2fe8a919f16ca7a70c42976 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmoud Date: Thu, 25 Sep 2025 10:52:27 +0300 Subject: [PATCH 7/7] Attempt to fix Windows build --- CMakeLists.txt | 1 + Sources/SwiftLanguageService/CMakeLists.txt | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index c7ad7e943..a2424622b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,7 @@ find_package(SwiftSyntax CONFIG REQUIRED) find_package(SwiftASN1 CONFIG REQUIRED) find_package(SwiftCrypto CONFIG REQUIRED) find_package(SwiftMarkdown CONFIG REQUIRED) +find_package(cmark-gfm CONFIG REQUIRED) include(SwiftSupport) diff --git a/Sources/SwiftLanguageService/CMakeLists.txt b/Sources/SwiftLanguageService/CMakeLists.txt index ef3635824..171b67fb9 100644 --- a/Sources/SwiftLanguageService/CMakeLists.txt +++ b/Sources/SwiftLanguageService/CMakeLists.txt @@ -58,9 +58,11 @@ target_link_libraries(SwiftLanguageService PUBLIC SourceKitLSP ToolchainRegistry IndexStoreDB - SwiftMarkdown::Markdown SwiftSyntax::SwiftBasicFormat SwiftSyntax::SwiftSyntax + SwiftMarkdown::Markdown + libcmark-gfm + libcmark-gfm-extensions ) target_link_libraries(SourceKitLSP PRIVATE