Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add "Add documentation" code action to stub out documentation for a function #1206

Merged
merged 3 commits into from
May 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/SourceKitLSP/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ target_sources(SourceKitLSP PRIVATE
Clang/ClangLanguageService.swift)
target_sources(SourceKitLSP PRIVATE
Swift/AdjustPositionToStartOfIdentifier.swift
Swift/CodeActions/AddDocumentation.swift
Swift/CodeActions/ConvertIntegerLiteral.swift
Swift/CodeActions/PackageManifestEdits.swift
Swift/CodeActions/SyntaxCodeActionProvider.swift
Expand Down
156 changes: 156 additions & 0 deletions Sources/SourceKitLSP/Swift/CodeActions/AddDocumentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2024 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 SwiftParser
import SwiftRefactor
import SwiftSyntax

/// Insert a documentation template associated with a function or macro.
///
/// ## Before
///
/// ```swift
/// static func refactor(syntax: DeclSyntax, in context: Void) -> DeclSyntax? {}
/// ```
///
/// ## After
///
/// ```swift
/// ///
/// /// - Parameters:
/// /// - syntax:
/// /// - context:
/// /// - Returns:
/// static func refactor(syntax: DeclSyntax, in context: Void) -> DeclSyntax? {}
/// ```
@_spi(Testing)
public struct AddDocumentation: EditRefactoringProvider {
@_spi(Testing)
public static func textRefactor(syntax: DeclSyntax, in context: Void) -> [SourceEdit] {
let hasDocumentation = syntax.leadingTrivia.contains(where: { trivia in
switch trivia {
case .blockComment(_), .docBlockComment(_), .lineComment(_), .docLineComment(_):
return true
default:
return false
}
})

guard !hasDocumentation else {
return []
}

let indentation = [.newlines(1)] + syntax.leadingTrivia.lastLineIndentation()
var content: [TriviaPiece] = []
content.append(contentsOf: indentation)
content.append(.docLineComment("/// A description"))

if let parameters = syntax.parameters?.parameters {
if let onlyParam = parameters.only {
let paramToken = onlyParam.secondName?.text ?? onlyParam.firstName.text
content.append(contentsOf: indentation)
content.append(.docLineComment("/// - Parameter \(paramToken):"))
} else {
content.append(contentsOf: indentation)
content.append(.docLineComment("/// - Parameters:"))
content.append(
contentsOf: parameters.flatMap({ param in
indentation + [
.docLineComment("/// - \(param.secondName?.text ?? param.firstName.text):")
]
})
)
content.append(contentsOf: indentation)
content.append(.docLineComment("///"))
}
}

if syntax.throwsKeyword != nil {
content.append(contentsOf: indentation)
content.append(.docLineComment("/// - Throws:"))
}

if syntax.returnType != nil {
content.append(contentsOf: indentation)
content.append(.docLineComment("/// - Returns:"))
}

let insertPos = syntax.position
return [
SourceEdit(
range: insertPos..<insertPos,
replacement: Trivia(pieces: content).description
)
]
}
}

extension AddDocumentation: SyntaxRefactoringCodeActionProvider {
static var title: String { "Add documentation" }
}

extension DeclSyntax {
fileprivate var parameters: FunctionParameterClauseSyntax? {
switch self.syntaxNodeType {
case is FunctionDeclSyntax.Type:
return self.as(FunctionDeclSyntax.self)!.signature.parameterClause
case is SubscriptDeclSyntax.Type:
return self.as(SubscriptDeclSyntax.self)!.parameterClause
case is InitializerDeclSyntax.Type:
return self.as(InitializerDeclSyntax.self)!.signature.parameterClause
case is MacroDeclSyntax.Type:
return self.as(MacroDeclSyntax.self)!.signature.parameterClause
default:
return nil
}
}

fileprivate var throwsKeyword: TokenSyntax? {
switch self.syntaxNodeType {
case is FunctionDeclSyntax.Type:
return self.as(FunctionDeclSyntax.self)!.signature.effectSpecifiers?
.throwsClause?.throwsSpecifier
case is InitializerDeclSyntax.Type:
return self.as(InitializerDeclSyntax.self)!.signature.effectSpecifiers?
.throwsClause?.throwsSpecifier
default:
return nil
}
}

fileprivate var returnType: TypeSyntax? {
switch self.syntaxNodeType {
case is FunctionDeclSyntax.Type:
return self.as(FunctionDeclSyntax.self)!.signature.returnClause?.type
case is SubscriptDeclSyntax.Type:
return self.as(SubscriptDeclSyntax.self)!.returnClause.type
case is InitializerDeclSyntax.Type:
return self.as(InitializerDeclSyntax.self)!.signature.returnClause?.type
case is MacroDeclSyntax.Type:
return self.as(MacroDeclSyntax.self)!.signature.returnClause?.type
default:
return nil
}
}
}

extension Trivia {
/// Produce trivia from the last newline to the end, dropping anything
/// prior to that.
fileprivate func lastLineIndentation() -> Trivia {
guard let lastNewline = pieces.lastIndex(where: { $0.isNewline }) else {
return self
}

return Trivia(pieces: pieces[(lastNewline + 1)...])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import SwiftRefactor
/// List of all of the syntactic code action providers, which can be used
/// to produce code actions using only the swift-syntax tree of a file.
let allSyntaxCodeActions: [SyntaxCodeActionProvider.Type] = [
AddDocumentation.self,
AddSeparatorsToIntegerLiteral.self,
ConvertIntegerLiteral.self,
FormatRawStringLiteral.self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import SwiftSyntax

/// Protocol that adapts a SyntaxRefactoringProvider (that comes from
/// swift-syntax) into a SyntaxCodeActionProvider.
protocol SyntaxRefactoringCodeActionProvider: SyntaxCodeActionProvider, SyntaxRefactoringProvider {
protocol SyntaxRefactoringCodeActionProvider: SyntaxCodeActionProvider, EditRefactoringProvider {
static var title: String { get }
}

Expand All @@ -31,20 +31,23 @@ extension SyntaxRefactoringCodeActionProvider where Self.Context == Void {
return []
}

guard let refactored = Self.refactor(syntax: node) else {
let sourceEdits = Self.textRefactor(syntax: node)
if sourceEdits.isEmpty {
return []
}

let edit = TextEdit(
range: scope.snapshot.range(of: node),
newText: refactored.description
)
let textEdits = sourceEdits.map { edit in
TextEdit(
range: scope.snapshot.range(of: edit.range),
newText: edit.replacement
)
}

return [
CodeAction(
title: Self.title,
kind: .refactorInline,
edit: WorkspaceEdit(changes: [scope.snapshot.uri: [edit]])
edit: WorkspaceEdit(changes: [scope.snapshot.uri: textEdits])
)
]
}
Expand Down
48 changes: 47 additions & 1 deletion Tests/SourceKitLSPTests/CodeActionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,23 @@ final class CodeActionTests: XCTestCase {
command: expectedCommand
)

XCTAssertEqual(result, .codeActions([expectedCodeAction]))
guard case .codeActions(var resultActions) = result else {
XCTFail("Result doesn't have code actions: \(String(describing: result))")
return
}

// Filter out "Add documentation"; we test it elsewhere
if let addDocIndex = resultActions.firstIndex(where: {
$0.title == "Add documentation"
}
) {
resultActions.remove(at: addDocIndex)
} else {
XCTFail("Missing 'Add documentation'.")
return
}

XCTAssertEqual(resultActions, [expectedCodeAction])
}

func testCodeActionsRemovePlaceholders() async throws {
Expand Down Expand Up @@ -441,6 +457,36 @@ final class CodeActionTests: XCTestCase {
try await fulfillmentOfOrThrow([editReceived])
}

func testAddDocumentationCodeActionResult() async throws {
let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport())
let uri = DocumentURI.for(.swift)
let positions = testClient.openDocument(
"""
2️⃣func refacto1️⃣r(syntax: DeclSyntax, in context: Void) -> DeclSyntax? { }3️⃣
""",
uri: uri
)

let testPosition = positions["1️⃣"]
let request = CodeActionRequest(
range: Range(testPosition),
context: .init(),
textDocument: TextDocumentIdentifier(uri)
)
let result = try await testClient.send(request)

guard case .codeActions(let codeActions) = result else {
XCTFail("Expected code actions")
return
}

// Make sure we get an add-documentation action.
let addDocAction = codeActions.first { action in
return action.title == "Add documentation"
}
XCTAssertNotNil(addDocAction)
}

func testCodeActionForFixItsProducedBySwiftSyntax() async throws {
let project = try await MultiFileTestProject(files: [
"test.swift": "protocol 1️⃣Multi 2️⃣ident 3️⃣{}",
Expand Down
21 changes: 11 additions & 10 deletions Tests/SourceKitLSPTests/PullDiagnosticsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,17 +93,18 @@ final class PullDiagnosticsTests: XCTestCase {
return
}

XCTAssertEqual(actions.count, 1)
let action = try XCTUnwrap(actions.first)
// Allow the action message to be the one before or after
// https://github.com/apple/swift/pull/67909, ensuring this test passes with
// a sourcekitd that contains the change from that PR as well as older
// toolchains that don't contain the change yet.
XCTAssertEqual(actions.count, 2)
XCTAssert(
[
"Add stubs for conformance",
"Do you want to add protocol stubs?",
].contains(action.title)
actions.contains { action in
// Allow the action message to be the one before or after
// https://github.com/apple/swift/pull/67909, ensuring this test passes with
// a sourcekitd that contains the change from that PR as well as older
// toolchains that don't contain the change yet.
[
"Add stubs for conformance",
"Do you want to add protocol stubs?",
].contains(action.title)
}
)
}

Expand Down