diff --git a/Sources/SwiftLanguageService/CodeCompletionSession.swift b/Sources/SwiftLanguageService/CodeCompletionSession.swift index 26d42e686..3d7b41324 100644 --- a/Sources/SwiftLanguageService/CodeCompletionSession.swift +++ b/Sources/SwiftLanguageService/CodeCompletionSession.swift @@ -490,17 +490,27 @@ class CodeCompletionSession { let text = rewriteSourceKitPlaceholders(in: insertText, clientSupportsSnippets: clientSupportsSnippets) let isInsertTextSnippet = clientSupportsSnippets && text != insertText + 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. 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) { + insertText = snippetText + isKeywordSnippet = true + } else { + insertText = text + } + var textEdit = self.computeCompletionTextEdit( completionPos: completionPos, requestPosition: requestPosition, utf8CodeUnitsToErase: utf8CodeUnitsToErase, - newText: text, + newText: insertText, snapshot: snapshot ) - let kind: sourcekitd_api_uid_t? = value[sourcekitd.keys.kind] - let completionKind = kind?.asCompletionItemKind(sourcekitd.values) ?? .value - 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 +587,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 +714,50 @@ 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 } + + 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\(indent)${0:}\n}" + case "for": + return "for ${1:item} in ${2:sequence} {\n\(indent)${0:}\n}" + case "while": + return "while ${1:condition} {\n\(indent)${0:}\n}" + case "guard": + return "guard ${1:condition} else {\n\(indent)${0:}\n}" + case "switch": + return "switch ${1:value} {\n\(indent)case ${2:pattern}:\n\(doubleIndent)${0:}\n}" + case "repeat": + return "repeat {\n\(indent)${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..2405b5013 --- /dev/null +++ b/Tests/SourceKitLSPTests/SwiftCompletetionSnippetTests.swift @@ -0,0 +1,302 @@ +//===----------------------------------------------------------------------===// +// +// 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 +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️⃣"]) + ) + + let ifItem = try XCTUnwrap(completions.items.first(where: { $0.label == "if" })) + + XCTAssertEqual(ifItem.kind, .keyword) + XCTAssertEqual(ifItem.insertTextFormat, .snippet) + + let insertText = try XCTUnwrap(ifItem.insertText) + 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️⃣"]) + ) + + let forItem = try XCTUnwrap(completions.items.first(where: { $0.label == "for" })) + + XCTAssertEqual(forItem.kind, .keyword) + XCTAssertEqual(forItem.insertTextFormat, .snippet) + + let insertText = try XCTUnwrap(forItem.insertText) + 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️⃣"]) + ) + + let whileItem = try XCTUnwrap(completions.items.first(where: { $0.label == "while" })) + + XCTAssertEqual(whileItem.kind, .keyword) + XCTAssertEqual(whileItem.insertTextFormat, .snippet) + + let insertText = try XCTUnwrap(whileItem.insertText) + 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️⃣"]) + ) + + let guardItem = try XCTUnwrap(completions.items.first(where: { $0.label == "guard" })) + + XCTAssertEqual(guardItem.kind, .keyword) + XCTAssertEqual(guardItem.insertTextFormat, .snippet) + + let insertText = try XCTUnwrap(guardItem.insertText) + 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️⃣"]) + ) + + let switchItem = try XCTUnwrap(completions.items.first(where: { $0.label == "switch" })) + + XCTAssertEqual(switchItem.kind, .keyword) + XCTAssertEqual(switchItem.insertTextFormat, .snippet) + + let insertText = try XCTUnwrap(switchItem.insertText) + 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️⃣"]) + ) + + let repeatItem = try XCTUnwrap(completions.items.first(where: { $0.label == "repeat" })) + + XCTAssertEqual(repeatItem.kind, .keyword) + XCTAssertEqual(repeatItem.insertTextFormat, .snippet) + + let insertText = try XCTUnwrap(repeatItem.insertText) + 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️⃣"]) + ) + + 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)'") + } +}