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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this evaluates to true, we can avoid computing the previous textEdit. Since almost all parameters are the same, it should even be sufficient to just use snippetText for text and just have a single call to computeCompletionTextEdit.

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think this is correct. text will have SourceKit placeholders replaced by LSP placeholders while insertText still contains SourceKit placeholders. I expect that we have tests that fail with this change. Did you run all tests locally?

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}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not assume that the source file is indented with tabs. We have BasicFormat.inferIndenatation to infer a source files’s indentation and should rely and that instead.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add the copyright header to this file?

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
}
Comment on lines +35 to +38
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
guard let ifItem = completions.items.first(where: { $0.label == "if" }) else {
XCTFail("No completion item with label 'if'")
return
}
let ifItem = try XCTUnwrap(completions.items.first(where: { $0.label == "if" }))

Same in a couple more places below


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")
}
}