-
Notifications
You must be signed in to change notification settings - Fork 325
[#2118] Implement keyword snippets for control-flow keywords (if/for/while/etc.) #2387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don’t think this is correct. |
||
| 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}" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,247 @@ | ||||||||||||
| @_spi(SourceKitLSP) import LanguageServerProtocol | ||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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") | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
There was a problem hiding this comment.
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 previoustextEdit. Since almost all parameters are the same, it should even be sufficient to just usesnippetTextfortextand just have a single call tocomputeCompletionTextEdit.