Skip to content

Commit ccea54f

Browse files
authored
Merge pull request #1206 from DougGregor/add-documentation-code-action
Add "Add documentation" code action to stub out documentation for a function
2 parents ee6d696 + c298626 commit ccea54f

File tree

7 files changed

+491
-18
lines changed

7 files changed

+491
-18
lines changed

Sources/SourceKitLSP/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ target_sources(SourceKitLSP PRIVATE
2222
Clang/ClangLanguageService.swift)
2323
target_sources(SourceKitLSP PRIVATE
2424
Swift/AdjustPositionToStartOfIdentifier.swift
25+
Swift/CodeActions/AddDocumentation.swift
2526
Swift/CodeActions/ConvertIntegerLiteral.swift
2627
Swift/CodeActions/PackageManifestEdits.swift
2728
Swift/CodeActions/SyntaxCodeActionProvider.swift
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2024 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 SwiftParser
14+
import SwiftRefactor
15+
import SwiftSyntax
16+
17+
/// Insert a documentation template associated with a function or macro.
18+
///
19+
/// ## Before
20+
///
21+
/// ```swift
22+
/// static func refactor(syntax: DeclSyntax, in context: Void) -> DeclSyntax? {}
23+
/// ```
24+
///
25+
/// ## After
26+
///
27+
/// ```swift
28+
/// ///
29+
/// /// - Parameters:
30+
/// /// - syntax:
31+
/// /// - context:
32+
/// /// - Returns:
33+
/// static func refactor(syntax: DeclSyntax, in context: Void) -> DeclSyntax? {}
34+
/// ```
35+
@_spi(Testing)
36+
public struct AddDocumentation: EditRefactoringProvider {
37+
@_spi(Testing)
38+
public static func textRefactor(syntax: DeclSyntax, in context: Void) -> [SourceEdit] {
39+
let hasDocumentation = syntax.leadingTrivia.contains(where: { trivia in
40+
switch trivia {
41+
case .blockComment(_), .docBlockComment(_), .lineComment(_), .docLineComment(_):
42+
return true
43+
default:
44+
return false
45+
}
46+
})
47+
48+
guard !hasDocumentation else {
49+
return []
50+
}
51+
52+
let indentation = [.newlines(1)] + syntax.leadingTrivia.lastLineIndentation()
53+
var content: [TriviaPiece] = []
54+
content.append(contentsOf: indentation)
55+
content.append(.docLineComment("/// A description"))
56+
57+
if let parameters = syntax.parameters?.parameters {
58+
if let onlyParam = parameters.only {
59+
let paramToken = onlyParam.secondName?.text ?? onlyParam.firstName.text
60+
content.append(contentsOf: indentation)
61+
content.append(.docLineComment("/// - Parameter \(paramToken):"))
62+
} else {
63+
content.append(contentsOf: indentation)
64+
content.append(.docLineComment("/// - Parameters:"))
65+
content.append(
66+
contentsOf: parameters.flatMap({ param in
67+
indentation + [
68+
.docLineComment("/// - \(param.secondName?.text ?? param.firstName.text):")
69+
]
70+
})
71+
)
72+
content.append(contentsOf: indentation)
73+
content.append(.docLineComment("///"))
74+
}
75+
}
76+
77+
if syntax.throwsKeyword != nil {
78+
content.append(contentsOf: indentation)
79+
content.append(.docLineComment("/// - Throws:"))
80+
}
81+
82+
if syntax.returnType != nil {
83+
content.append(contentsOf: indentation)
84+
content.append(.docLineComment("/// - Returns:"))
85+
}
86+
87+
let insertPos = syntax.position
88+
return [
89+
SourceEdit(
90+
range: insertPos..<insertPos,
91+
replacement: Trivia(pieces: content).description
92+
)
93+
]
94+
}
95+
}
96+
97+
extension AddDocumentation: SyntaxRefactoringCodeActionProvider {
98+
static var title: String { "Add documentation" }
99+
}
100+
101+
extension DeclSyntax {
102+
fileprivate var parameters: FunctionParameterClauseSyntax? {
103+
switch self.syntaxNodeType {
104+
case is FunctionDeclSyntax.Type:
105+
return self.as(FunctionDeclSyntax.self)!.signature.parameterClause
106+
case is SubscriptDeclSyntax.Type:
107+
return self.as(SubscriptDeclSyntax.self)!.parameterClause
108+
case is InitializerDeclSyntax.Type:
109+
return self.as(InitializerDeclSyntax.self)!.signature.parameterClause
110+
case is MacroDeclSyntax.Type:
111+
return self.as(MacroDeclSyntax.self)!.signature.parameterClause
112+
default:
113+
return nil
114+
}
115+
}
116+
117+
fileprivate var throwsKeyword: TokenSyntax? {
118+
switch self.syntaxNodeType {
119+
case is FunctionDeclSyntax.Type:
120+
return self.as(FunctionDeclSyntax.self)!.signature.effectSpecifiers?
121+
.throwsClause?.throwsSpecifier
122+
case is InitializerDeclSyntax.Type:
123+
return self.as(InitializerDeclSyntax.self)!.signature.effectSpecifiers?
124+
.throwsClause?.throwsSpecifier
125+
default:
126+
return nil
127+
}
128+
}
129+
130+
fileprivate var returnType: TypeSyntax? {
131+
switch self.syntaxNodeType {
132+
case is FunctionDeclSyntax.Type:
133+
return self.as(FunctionDeclSyntax.self)!.signature.returnClause?.type
134+
case is SubscriptDeclSyntax.Type:
135+
return self.as(SubscriptDeclSyntax.self)!.returnClause.type
136+
case is InitializerDeclSyntax.Type:
137+
return self.as(InitializerDeclSyntax.self)!.signature.returnClause?.type
138+
case is MacroDeclSyntax.Type:
139+
return self.as(MacroDeclSyntax.self)!.signature.returnClause?.type
140+
default:
141+
return nil
142+
}
143+
}
144+
}
145+
146+
extension Trivia {
147+
/// Produce trivia from the last newline to the end, dropping anything
148+
/// prior to that.
149+
fileprivate func lastLineIndentation() -> Trivia {
150+
guard let lastNewline = pieces.lastIndex(where: { $0.isNewline }) else {
151+
return self
152+
}
153+
154+
return Trivia(pieces: pieces[(lastNewline + 1)...])
155+
}
156+
}

Sources/SourceKitLSP/Swift/CodeActions/SyntaxCodeActions.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import SwiftRefactor
1515
/// List of all of the syntactic code action providers, which can be used
1616
/// to produce code actions using only the swift-syntax tree of a file.
1717
let allSyntaxCodeActions: [SyntaxCodeActionProvider.Type] = [
18+
AddDocumentation.self,
1819
AddSeparatorsToIntegerLiteral.self,
1920
ConvertIntegerLiteral.self,
2021
FormatRawStringLiteral.self,

Sources/SourceKitLSP/Swift/CodeActions/SyntaxRefactoringCodeActionProvider.swift

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import SwiftSyntax
1616

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

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

34-
guard let refactored = Self.refactor(syntax: node) else {
34+
let sourceEdits = Self.textRefactor(syntax: node)
35+
if sourceEdits.isEmpty {
3536
return []
3637
}
3738

38-
let edit = TextEdit(
39-
range: scope.snapshot.range(of: node),
40-
newText: refactored.description
41-
)
39+
let textEdits = sourceEdits.map { edit in
40+
TextEdit(
41+
range: scope.snapshot.range(of: edit.range),
42+
newText: edit.replacement
43+
)
44+
}
4245

4346
return [
4447
CodeAction(
4548
title: Self.title,
4649
kind: .refactorInline,
47-
edit: WorkspaceEdit(changes: [scope.snapshot.uri: [edit]])
50+
edit: WorkspaceEdit(changes: [scope.snapshot.uri: textEdits])
4851
)
4952
]
5053
}

Tests/SourceKitLSPTests/CodeActionTests.swift

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,23 @@ final class CodeActionTests: XCTestCase {
348348
command: expectedCommand
349349
)
350350

351-
XCTAssertEqual(result, .codeActions([expectedCodeAction]))
351+
guard case .codeActions(var resultActions) = result else {
352+
XCTFail("Result doesn't have code actions: \(String(describing: result))")
353+
return
354+
}
355+
356+
// Filter out "Add documentation"; we test it elsewhere
357+
if let addDocIndex = resultActions.firstIndex(where: {
358+
$0.title == "Add documentation"
359+
}
360+
) {
361+
resultActions.remove(at: addDocIndex)
362+
} else {
363+
XCTFail("Missing 'Add documentation'.")
364+
return
365+
}
366+
367+
XCTAssertEqual(resultActions, [expectedCodeAction])
352368
}
353369

354370
func testCodeActionsRemovePlaceholders() async throws {
@@ -441,6 +457,36 @@ final class CodeActionTests: XCTestCase {
441457
try await fulfillmentOfOrThrow([editReceived])
442458
}
443459

460+
func testAddDocumentationCodeActionResult() async throws {
461+
let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport())
462+
let uri = DocumentURI.for(.swift)
463+
let positions = testClient.openDocument(
464+
"""
465+
2️⃣func refacto1️⃣r(syntax: DeclSyntax, in context: Void) -> DeclSyntax? { }3️⃣
466+
""",
467+
uri: uri
468+
)
469+
470+
let testPosition = positions["1️⃣"]
471+
let request = CodeActionRequest(
472+
range: Range(testPosition),
473+
context: .init(),
474+
textDocument: TextDocumentIdentifier(uri)
475+
)
476+
let result = try await testClient.send(request)
477+
478+
guard case .codeActions(let codeActions) = result else {
479+
XCTFail("Expected code actions")
480+
return
481+
}
482+
483+
// Make sure we get an add-documentation action.
484+
let addDocAction = codeActions.first { action in
485+
return action.title == "Add documentation"
486+
}
487+
XCTAssertNotNil(addDocAction)
488+
}
489+
444490
func testCodeActionForFixItsProducedBySwiftSyntax() async throws {
445491
let project = try await MultiFileTestProject(files: [
446492
"test.swift": "protocol 1️⃣Multi 2️⃣ident 3️⃣{}",

Tests/SourceKitLSPTests/PullDiagnosticsTests.swift

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,17 +93,18 @@ final class PullDiagnosticsTests: XCTestCase {
9393
return
9494
}
9595

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

0 commit comments

Comments
 (0)