From dde3c8427e6b41c10cdd1b41622fc728202389d0 Mon Sep 17 00:00:00 2001 From: Divya Prakash Date: Wed, 10 Dec 2025 20:42:39 +0530 Subject: [PATCH 1/2] 2118: Implement keyword snippets for control-flow keywords --- .../CodeCompletionSession.swift | 42 ++- .../SwiftCompletetionSnippetTests.swift | 247 ++++++++++++++++++ 2 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 Tests/SourceKitLSPTests/SwiftCompletetionSnippetTests.swift 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") + } +} From 2abad47760e731df2495ee2d30c6133039e2841d Mon Sep 17 00:00:00 2001 From: Divya Prakash Date: Thu, 18 Dec 2025 21:49:24 +0530 Subject: [PATCH 2/2] fix: resolve snippet issues --- .../CodeCompletionSession.swift | 62 ++++--- .../SwiftCompletetionSnippetTests.swift | 161 ++++++++++++------ 2 files changed, 147 insertions(+), 76 deletions(-) diff --git a/Sources/SwiftLanguageService/CodeCompletionSession.swift b/Sources/SwiftLanguageService/CodeCompletionSession.swift index c9c728103..3d7b41324 100644 --- a/Sources/SwiftLanguageService/CodeCompletionSession.swift +++ b/Sources/SwiftLanguageService/CodeCompletionSession.swift @@ -490,32 +490,27 @@ class CodeCompletionSession { let text = rewriteSourceKitPlaceholders(in: insertText, clientSupportsSnippets: clientSupportsSnippets) let isInsertTextSnippet = clientSupportsSnippets && text != insertText - var textEdit = self.computeCompletionTextEdit( - completionPos: completionPos, - requestPosition: requestPosition, - utf8CodeUnitsToErase: utf8CodeUnitsToErase, - newText: text, - snapshot: snapshot - ) - 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 + // Check if this is a keyword that should be converted to a snippet. If so, prefer the snippet text + // as the completion insert text and only compute the `TextEdit` once below. 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 + } else { + insertText = text } + var textEdit = self.computeCompletionTextEdit( + completionPos: completionPos, + requestPosition: requestPosition, + utf8CodeUnitsToErase: utf8CodeUnitsToErase, + newText: insertText, + snapshot: snapshot + ) + 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 @@ -725,19 +720,40 @@ class CodeCompletionSession { private func keywordSnippet(for keyword: String) -> String? { guard clientSupportsSnippets else { return nil } + func indentationUnitString() -> String { + // Convert the inferred Trivia (spaces/tabs) to a string we can embed into snippets. + if let trivia = indentationWidth { + for piece in trivia { + switch piece { + case .spaces(let n): + return String(repeating: " ", count: n) + case .tabs(let n): + return String(repeating: "\t", count: n) + default: + continue + } + } + } + // Fallback to a single tab to preserve previous behaviour when indentation cannot be inferred. + return "\t" + } + + let indent = indentationUnitString() + let doubleIndent = indent + indent + switch keyword { case "if": - return "if ${1:condition} {\n\t${0:}\n}" + return "if ${1:condition} {\n\(indent)${0:}\n}" case "for": - return "for ${1:item} in ${2:sequence} {\n\t${0:}\n}" + return "for ${1:item} in ${2:sequence} {\n\(indent)${0:}\n}" case "while": - return "while ${1:condition} {\n\t${0:}\n}" + return "while ${1:condition} {\n\(indent)${0:}\n}" case "guard": - return "guard ${1:condition} else {\n\t${0:}\n}" + return "guard ${1:condition} else {\n\(indent)${0:}\n}" case "switch": - return "switch ${1:value} {\n\tcase ${2:pattern}:\n\t\t${0:}\n}" + return "switch ${1:value} {\n\(indent)case ${2:pattern}:\n\(doubleIndent)${0:}\n}" case "repeat": - return "repeat {\n\t${0:}\n} while ${1:condition}" + return "repeat {\n\(indent)${0:}\n} while ${1:condition}" default: return nil } diff --git a/Tests/SourceKitLSPTests/SwiftCompletetionSnippetTests.swift b/Tests/SourceKitLSPTests/SwiftCompletetionSnippetTests.swift index 7bbe176cd..2405b5013 100644 --- a/Tests/SourceKitLSPTests/SwiftCompletetionSnippetTests.swift +++ b/Tests/SourceKitLSPTests/SwiftCompletetionSnippetTests.swift @@ -1,3 +1,15 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2025 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 +// +//===----------------------------------------------------------------------===// + @_spi(SourceKitLSP) import LanguageServerProtocol import SKLogging import SKTestSupport @@ -32,20 +44,14 @@ final class SwiftCompletionSnippetTests: SourceKitLSPTestCase { 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 - } + let ifItem = try XCTUnwrap(completions.items.first(where: { $0.label == "if" })) XCTAssertEqual(ifItem.kind, .keyword) XCTAssertEqual(ifItem.insertTextFormat, .snippet) - guard let insertText = ifItem.insertText else { - XCTFail("Completion item for 'if' has no insertText") - return - } + let insertText = try XCTUnwrap(ifItem.insertText) XCTAssertTrue(insertText.contains("${1:condition}")) - XCTAssertTrue(insertText.contains("${0:")) + XCTAssertTrue(insertText.contains("${0:}")) } func testKeywordForProvidesSnippet() async throws { @@ -66,18 +72,12 @@ final class SwiftCompletionSnippetTests: SourceKitLSPTestCase { 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 - } + let forItem = try XCTUnwrap(completions.items.first(where: { $0.label == "for" })) XCTAssertEqual(forItem.kind, .keyword) XCTAssertEqual(forItem.insertTextFormat, .snippet) - guard let insertText = forItem.insertText else { - XCTFail("Completion item for 'for' has no insertText") - return - } + let insertText = try XCTUnwrap(forItem.insertText) XCTAssertTrue(insertText.contains("${1:item}")) XCTAssertTrue(insertText.contains("${2:sequence}")) } @@ -100,18 +100,12 @@ final class SwiftCompletionSnippetTests: SourceKitLSPTestCase { 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 - } + let whileItem = try XCTUnwrap(completions.items.first(where: { $0.label == "while" })) XCTAssertEqual(whileItem.kind, .keyword) XCTAssertEqual(whileItem.insertTextFormat, .snippet) - guard let insertText = whileItem.insertText else { - XCTFail("Completion item for 'while' has no insertText") - return - } + let insertText = try XCTUnwrap(whileItem.insertText) XCTAssertTrue(insertText.contains("${1:condition}")) } @@ -133,18 +127,12 @@ final class SwiftCompletionSnippetTests: SourceKitLSPTestCase { 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 - } + let guardItem = try XCTUnwrap(completions.items.first(where: { $0.label == "guard" })) XCTAssertEqual(guardItem.kind, .keyword) XCTAssertEqual(guardItem.insertTextFormat, .snippet) - guard let insertText = guardItem.insertText else { - XCTFail("Completion item for 'guard' has no insertText") - return - } + let insertText = try XCTUnwrap(guardItem.insertText) XCTAssertTrue(insertText.contains("${1:condition}")) XCTAssertTrue(insertText.contains("else")) } @@ -167,18 +155,12 @@ final class SwiftCompletionSnippetTests: SourceKitLSPTestCase { 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 - } + let switchItem = try XCTUnwrap(completions.items.first(where: { $0.label == "switch" })) XCTAssertEqual(switchItem.kind, .keyword) XCTAssertEqual(switchItem.insertTextFormat, .snippet) - guard let insertText = switchItem.insertText else { - XCTFail("Completion item for 'switch' has no insertText") - return - } + let insertText = try XCTUnwrap(switchItem.insertText) XCTAssertTrue(insertText.contains("${1:value}")) XCTAssertTrue(insertText.contains("case")) } @@ -201,18 +183,12 @@ final class SwiftCompletionSnippetTests: SourceKitLSPTestCase { 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 - } + let repeatItem = try XCTUnwrap(completions.items.first(where: { $0.label == "repeat" })) XCTAssertEqual(repeatItem.kind, .keyword) XCTAssertEqual(repeatItem.insertTextFormat, .snippet) - guard let insertText = repeatItem.insertText else { - XCTFail("Completion item for 'repeat' has no insertText") - return - } + let insertText = try XCTUnwrap(repeatItem.insertText) XCTAssertTrue(insertText.contains("while")) } @@ -235,13 +211,92 @@ final class SwiftCompletionSnippetTests: SourceKitLSPTestCase { 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 - } + let ifItem = try XCTUnwrap(completions.items.first(where: { $0.label == "if" })) XCTAssertEqual(ifItem.kind, .keyword) XCTAssertEqual(ifItem.insertTextFormat, .plain) XCTAssertEqual(ifItem.insertText, "if") } + + func testInsertTextAndTextEditAreConsistent() 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️⃣"]) + ) + + for item in completions.items { + guard let insertText = item.insertText else { continue } + // Skip SourceKit's implicit method call labels (e.g. "funcName()") + if item.label.contains("(") && item.label.contains(")") { continue } + + if case .textEdit(let te) = item.textEdit { + XCTAssertEqual(insertText, te.newText, "insertText and textEdit.newText differ for item '\(item.label)'") + } + } + } + + func testKeywordSnippetUsesInferredSpacesIndentation() async throws { + try await SkipUnless.sourcekitdSupportsPlugin() + + let testClient = try await TestSourceKitLSPClient(capabilities: snippetCapabilities) + let uri = DocumentURI(for: .swift) + let positions = testClient.openDocument( + """ + func test() { + let a = 1 + let b = 2 + 1️⃣ + } + """, + uri: uri + ) + + let completions = try await testClient.send( + CompletionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"]) + ) + + let ifItem = try XCTUnwrap(completions.items.first(where: { $0.label == "if" })) + let insertText = try XCTUnwrap(ifItem.insertText) + + // Expect newline + two spaces before the final placeholder + XCTAssertTrue(insertText.contains("\n ${0:}"), "expected two-space indentation in snippet, got: '\(insertText)'") + } + + func testKeywordSnippetUsesInferredTabsIndentation() async throws { + try await SkipUnless.sourcekitdSupportsPlugin() + + let testClient = try await TestSourceKitLSPClient(capabilities: snippetCapabilities) + let uri = DocumentURI(for: .swift) + // Use raw tabs \t for indentation to ensure server picks up Tab style. + let positions = testClient.openDocument( + """ + func test() { + \tlet a = 1 + \tlet b = 2 + \t1️⃣ + } + """, + uri: uri + ) + + let completions = try await testClient.send( + CompletionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"]) + ) + + let ifItem = try XCTUnwrap(completions.items.first(where: { $0.label == "if" })) + let insertText = try XCTUnwrap(ifItem.insertText) + XCTAssertTrue(insertText.contains("\n\t"), "expected tab indentation in snippet, got: '\(insertText)'") + } }