diff --git a/Sources/SwiftLanguageService/CodeCompletionSession.swift b/Sources/SwiftLanguageService/CodeCompletionSession.swift index 26d42e686..c9c728103 100644 --- a/Sources/SwiftLanguageService/CodeCompletionSession.swift +++ b/Sources/SwiftLanguageService/CodeCompletionSession.swift @@ -501,6 +501,21 @@ class CodeCompletionSession { let kind: sourcekitd_api_uid_t? = value[sourcekitd.keys.kind] let completionKind = kind?.asCompletionItemKind(sourcekitd.values) ?? .value + // Check if this is a keyword that should be converted to a snippet + var isKeywordSnippet = false + if completionKind == .keyword, let snippetText = keywordSnippet(for: name) { + let snippetTextEdit = self.computeCompletionTextEdit( + completionPos: completionPos, + requestPosition: requestPosition, + utf8CodeUnitsToErase: utf8CodeUnitsToErase, + newText: snippetText, + snapshot: snapshot + ) + textEdit = snippetTextEdit + insertText = snippetText + isKeywordSnippet = true + } + if completionKind == .method || completionKind == .function, name.first == "(", name.last == ")" { // sourcekitd makes an assumption that the editor inserts a matching `)` when the user types a `(` to start // argument completions and thus does not contain the closing parentheses in the insert text. Since we can't @@ -577,8 +592,8 @@ class CodeCompletionSession { deprecated: notRecommended, sortText: sortText, filterText: filterName, - insertText: text, - insertTextFormat: isInsertTextSnippet ? .snippet : .plain, + insertText: insertText, + insertTextFormat: (isInsertTextSnippet || isKeywordSnippet) ? .snippet : .plain, textEdit: CompletionItemEdit.textEdit(textEdit), data: data.encodeToLSPAny() ) @@ -704,6 +719,29 @@ class CodeCompletionSession { return Position(line: completionPos.line, utf16index: deletionStartUtf16Offset) } + + /// Generate a snippet for control flow keywords like if, for, while, etc. + /// Returns the snippet text if the keyword is a control flow keyword and snippets are supported, otherwise nil. + private func keywordSnippet(for keyword: String) -> String? { + guard clientSupportsSnippets else { return nil } + + switch keyword { + case "if": + return "if ${1:condition} {\n\t${0:}\n}" + case "for": + return "for ${1:item} in ${2:sequence} {\n\t${0:}\n}" + case "while": + return "while ${1:condition} {\n\t${0:}\n}" + case "guard": + return "guard ${1:condition} else {\n\t${0:}\n}" + case "switch": + return "switch ${1:value} {\n\tcase ${2:pattern}:\n\t\t${0:}\n}" + case "repeat": + return "repeat {\n\t${0:}\n} while ${1:condition}" + default: + return nil + } + } } extension CodeCompletionSession: CustomStringConvertible { diff --git a/Tests/SourceKitLSPTests/SwiftCompletetionSnippetTests.swift b/Tests/SourceKitLSPTests/SwiftCompletetionSnippetTests.swift new file mode 100644 index 000000000..7bbe176cd --- /dev/null +++ b/Tests/SourceKitLSPTests/SwiftCompletetionSnippetTests.swift @@ -0,0 +1,247 @@ +@_spi(SourceKitLSP) import LanguageServerProtocol +import SKLogging +import SKTestSupport +import SourceKitLSP +import SwiftExtensions +import XCTest + +final class SwiftCompletionSnippetTests: SourceKitLSPTestCase { + private var snippetCapabilities = ClientCapabilities( + textDocument: TextDocumentClientCapabilities( + completion: TextDocumentClientCapabilities.Completion( + completionItem: TextDocumentClientCapabilities.Completion.CompletionItem(snippetSupport: true) + ) + ) + ) + + func testKeywordIfProvidesSnippet() async throws { + try await SkipUnless.sourcekitdSupportsPlugin() + + let testClient = try await TestSourceKitLSPClient(capabilities: snippetCapabilities) + let uri = DocumentURI(for: .swift) + let positions = testClient.openDocument( + """ + func test() { + 1️⃣ + } + """, + uri: uri + ) + + let completions = try await testClient.send( + CompletionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"]) + ) + + guard let ifItem = completions.items.first(where: { $0.label == "if" }) else { + XCTFail("No completion item with label 'if'") + return + } + + XCTAssertEqual(ifItem.kind, .keyword) + XCTAssertEqual(ifItem.insertTextFormat, .snippet) + + guard let insertText = ifItem.insertText else { + XCTFail("Completion item for 'if' has no insertText") + return + } + XCTAssertTrue(insertText.contains("${1:condition}")) + XCTAssertTrue(insertText.contains("${0:")) + } + + func testKeywordForProvidesSnippet() async throws { + try await SkipUnless.sourcekitdSupportsPlugin() + + let testClient = try await TestSourceKitLSPClient(capabilities: snippetCapabilities) + let uri = DocumentURI(for: .swift) + let positions = testClient.openDocument( + """ + func test() { + 1️⃣ + } + """, + uri: uri + ) + + let completions = try await testClient.send( + CompletionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"]) + ) + + guard let forItem = completions.items.first(where: { $0.label == "for" }) else { + XCTFail("No completion item with label 'for'") + return + } + + XCTAssertEqual(forItem.kind, .keyword) + XCTAssertEqual(forItem.insertTextFormat, .snippet) + + guard let insertText = forItem.insertText else { + XCTFail("Completion item for 'for' has no insertText") + return + } + XCTAssertTrue(insertText.contains("${1:item}")) + XCTAssertTrue(insertText.contains("${2:sequence}")) + } + + func testKeywordWhileProvidesSnippet() async throws { + try await SkipUnless.sourcekitdSupportsPlugin() + + let testClient = try await TestSourceKitLSPClient(capabilities: snippetCapabilities) + let uri = DocumentURI(for: .swift) + let positions = testClient.openDocument( + """ + func test() { + 1️⃣ + } + """, + uri: uri + ) + + let completions = try await testClient.send( + CompletionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"]) + ) + + guard let whileItem = completions.items.first(where: { $0.label == "while" }) else { + XCTFail("No completion item with label 'while'") + return + } + + XCTAssertEqual(whileItem.kind, .keyword) + XCTAssertEqual(whileItem.insertTextFormat, .snippet) + + guard let insertText = whileItem.insertText else { + XCTFail("Completion item for 'while' has no insertText") + return + } + XCTAssertTrue(insertText.contains("${1:condition}")) + } + + func testKeywordGuardProvidesSnippet() async throws { + try await SkipUnless.sourcekitdSupportsPlugin() + + let testClient = try await TestSourceKitLSPClient(capabilities: snippetCapabilities) + let uri = DocumentURI(for: .swift) + let positions = testClient.openDocument( + """ + func test() { + 1️⃣ + } + """, + uri: uri + ) + + let completions = try await testClient.send( + CompletionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"]) + ) + + guard let guardItem = completions.items.first(where: { $0.label == "guard" }) else { + XCTFail("No completion item with label 'guard'") + return + } + + XCTAssertEqual(guardItem.kind, .keyword) + XCTAssertEqual(guardItem.insertTextFormat, .snippet) + + guard let insertText = guardItem.insertText else { + XCTFail("Completion item for 'guard' has no insertText") + return + } + XCTAssertTrue(insertText.contains("${1:condition}")) + XCTAssertTrue(insertText.contains("else")) + } + + func testKeywordSwitchProvidesSnippet() async throws { + try await SkipUnless.sourcekitdSupportsPlugin() + + let testClient = try await TestSourceKitLSPClient(capabilities: snippetCapabilities) + let uri = DocumentURI(for: .swift) + let positions = testClient.openDocument( + """ + func test() { + 1️⃣ + } + """, + uri: uri + ) + + let completions = try await testClient.send( + CompletionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"]) + ) + + guard let switchItem = completions.items.first(where: { $0.label == "switch" }) else { + XCTFail("No completion item with label 'switch'") + return + } + + XCTAssertEqual(switchItem.kind, .keyword) + XCTAssertEqual(switchItem.insertTextFormat, .snippet) + + guard let insertText = switchItem.insertText else { + XCTFail("Completion item for 'switch' has no insertText") + return + } + XCTAssertTrue(insertText.contains("${1:value}")) + XCTAssertTrue(insertText.contains("case")) + } + + func testKeywordRepeatProvidesSnippet() async throws { + try await SkipUnless.sourcekitdSupportsPlugin() + + let testClient = try await TestSourceKitLSPClient(capabilities: snippetCapabilities) + let uri = DocumentURI(for: .swift) + let positions = testClient.openDocument( + """ + func test() { + 1️⃣ + } + """, + uri: uri + ) + + let completions = try await testClient.send( + CompletionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"]) + ) + + guard let repeatItem = completions.items.first(where: { $0.label == "repeat" }) else { + XCTFail("No completion item with label 'repeat'") + return + } + + XCTAssertEqual(repeatItem.kind, .keyword) + XCTAssertEqual(repeatItem.insertTextFormat, .snippet) + + guard let insertText = repeatItem.insertText else { + XCTFail("Completion item for 'repeat' has no insertText") + return + } + XCTAssertTrue(insertText.contains("while")) + } + + func testKeywordWithoutSnippetSupport() async throws { + try await SkipUnless.sourcekitdSupportsPlugin() + + // Client without snippet support should get plain keywords + let testClient = try await TestSourceKitLSPClient() + let uri = DocumentURI(for: .swift) + let positions = testClient.openDocument( + """ + func test() { + 1️⃣ + } + """, + uri: uri + ) + + let completions = try await testClient.send( + CompletionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"]) + ) + + guard let ifItem = completions.items.first(where: { $0.label == "if" }) else { + XCTFail("No completion item with label 'if'") + return + } + + XCTAssertEqual(ifItem.kind, .keyword) + XCTAssertEqual(ifItem.insertTextFormat, .plain) + XCTAssertEqual(ifItem.insertText, "if") + } +}