Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions Sources/SwiftLanguageService/CodeCompletionSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
)
Expand Down Expand Up @@ -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 {
Expand Down
247 changes: 247 additions & 0 deletions Tests/SourceKitLSPTests/SwiftCompletetionSnippetTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}