diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift b/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift index 1f11a7e3..c49f8907 100644 --- a/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift +++ b/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift @@ -23,7 +23,8 @@ public protocol GitHubCopilotSuggestionServiceType { tabSize: Int, indentSize: Int, usesTabsForIndentation: Bool, - ignoreSpaceOnlySuggestions: Bool + ignoreSpaceOnlySuggestions: Bool, + ignoreTrailingNewLinesAndSpaces: Bool ) async throws -> [CodeSuggestion] func notifyAccepted(_ completion: CodeSuggestion) async func notifyRejected(_ completions: [CodeSuggestion]) async @@ -269,7 +270,8 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, tabSize: Int, indentSize: Int, usesTabsForIndentation: Bool, - ignoreSpaceOnlySuggestions: Bool + ignoreSpaceOnlySuggestions: Bool, + ignoreTrailingNewLinesAndSpaces: Bool ) async throws -> [CodeSuggestion] { let languageId = languageIdentifierFromFileURL(fileURL) @@ -313,7 +315,7 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, return true } .map { - if UserDefaults.shared.value(for: \.gitHubCopilotIgnoreTrailingNewLines) { + if ignoreTrailingNewLinesAndSpaces { var updated = $0 var text = updated.text[...] while let last = text.last, last.isNewline || last.isWhitespace { diff --git a/Core/Sources/PromptToCodeService/CopilotPromptToCodeAPI.swift b/Core/Sources/PromptToCodeService/CopilotPromptToCodeAPI.swift index 0db4c337..3806082e 100644 --- a/Core/Sources/PromptToCodeService/CopilotPromptToCodeAPI.swift +++ b/Core/Sources/PromptToCodeService/CopilotPromptToCodeAPI.swift @@ -70,7 +70,8 @@ final class CopilotPromptToCodeAPI: PromptToCodeAPI { tabSize: indentSize, indentSize: indentSize, usesTabsForIndentation: usesTabsForIndentation, - ignoreSpaceOnlySuggestions: true + ignoreSpaceOnlySuggestions: true, + ignoreTrailingNewLinesAndSpaces: false ) try Task.checkCancellation() guard let first = result.first else { throw CancellationError() } diff --git a/Core/Sources/SuggestionInjector/SuggestionInjector.swift b/Core/Sources/SuggestionInjector/SuggestionInjector.swift index ac57de17..16216934 100644 --- a/Core/Sources/SuggestionInjector/SuggestionInjector.swift +++ b/Core/Sources/SuggestionInjector/SuggestionInjector.swift @@ -1,5 +1,5 @@ -import SuggestionModel import Foundation +import SuggestionModel let suggestionStart = "/*========== Copilot Suggestion" let suggestionEnd = "*///======== End of Copilot Suggestion" @@ -141,8 +141,6 @@ public struct SuggestionInjector { let end = completion.range.end let suggestionContent = completion.text - let _ = start.line < content.endIndex ? content[start.line] : nil - let firstRemovedLine = content[safe: start.line] let lastRemovedLine = content[safe: end.line] let startLine = max(0, start.line) @@ -177,12 +175,33 @@ public struct SuggestionInjector { } // appending suffix text not in range if needed. - let cursorCol = toBeInserted[toBeInserted.endIndex - 1].count - 1 let skipAppendingDueToContinueTyping = { - guard let first = toBeInserted.first?.dropLast(1), !first.isEmpty else { return false } - let droppedLast = lastRemovedLine?.dropLast(1) + guard let first = toBeInserted.first? + .dropLast((toBeInserted.first?.hasSuffix("\n") ?? false) ? 1 : 0), + !first.isEmpty else { return false } + guard let last = toBeInserted.last? + .dropLast((toBeInserted.last?.hasSuffix("\n") ?? false) ? 1 : 0), + !last.isEmpty else { return false } + let droppedLast = lastRemovedLine? + .dropLast((lastRemovedLine?.hasSuffix("\n") ?? false) ? 1 : 0) guard let droppedLast, !droppedLast.isEmpty else { return false } - return first.hasPrefix(droppedLast) + // case 1: user keeps typing as the suggestion suggests. + if first.hasPrefix(droppedLast) { + return true + } + // case 2: user also typed the suffix of the suggestion (or auto-completed by Xcode) + if cursorPosition.character < droppedLast.count { + let splitIndex = droppedLast.index( + droppedLast.startIndex, + offsetBy: cursorPosition.character + ) + let prefix = droppedLast[.. [SuggestionModel.CodeSuggestion] { completions } diff --git a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift index d9d5a4a8..c8258c66 100644 --- a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift +++ b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift @@ -26,7 +26,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) + var cursor = CursorPosition(line: 0, character: 1) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, cursorPosition: &cursor, @@ -69,7 +69,7 @@ final class AcceptSuggestionTests: XCTestCase { var extraInfo = SuggestionInjector.ExtraInfo() var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) + var cursor = CursorPosition(line: 0, character: 12) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, cursorPosition: &cursor, @@ -112,7 +112,7 @@ final class AcceptSuggestionTests: XCTestCase { var extraInfo = SuggestionInjector.ExtraInfo() var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) + var cursor = CursorPosition(line: 1, character: 12) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, cursorPosition: &cursor, @@ -155,7 +155,7 @@ final class AcceptSuggestionTests: XCTestCase { var extraInfo = SuggestionInjector.ExtraInfo() var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) + var cursor = CursorPosition(line: 1, character: 12) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, cursorPosition: &cursor, @@ -174,6 +174,88 @@ final class AcceptSuggestionTests: XCTestCase { } """) } + + func test_accept_suggestion_overlap_continue_typing_has_suffix_typed() async throws { + let content = """ + print("") + """ + let text = """ + print("Hello World!") + """ + let suggestion = CodeSuggestion( + text: text, + position: .init(line: 0, character: 6), + uuid: "", + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 6) + ), + displayText: "" + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakLines() + var cursor = CursorPosition(line: 0, character: 7) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 21)) + XCTAssertEqual(lines.joined(separator: ""), """ + print("Hello World!") + + """) + } + + func test_accept_suggestion_overlap_continue_typing_has_suffix_typed_suggestion_has_multiple_lines() async throws { + let content = """ + struct Cat {} + """ + let text = """ + struct Cat { + var name: String + var kind: String + } + """ + let suggestion = CodeSuggestion( + text: text, + position: .init(line: 0, character: 6), + uuid: "", + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 6) + ), + displayText: "" + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakLines() + var cursor = CursorPosition(line: 0, character: 12) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 3, character: 1)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct Cat { + var name: String + var kind: String + } + + """) + } func test_propose_suggestion_partial_overlap() async throws { let content = "func quickSort() {}}\n" @@ -199,7 +281,7 @@ final class AcceptSuggestionTests: XCTestCase { var extraInfo = SuggestionInjector.ExtraInfo() var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) + var cursor = CursorPosition(line: 0, character: 18) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, cursorPosition: &cursor, @@ -246,7 +328,7 @@ final class AcceptSuggestionTests: XCTestCase { var extraInfo = SuggestionInjector.ExtraInfo() var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) + var cursor = CursorPosition(line: 0, character: 18) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, cursorPosition: &cursor, @@ -296,7 +378,7 @@ final class AcceptSuggestionTests: XCTestCase { var extraInfo = SuggestionInjector.ExtraInfo() var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) + var cursor = CursorPosition(line: 0, character: 7) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, cursorPosition: &cursor, @@ -308,7 +390,7 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 4, character: 1)) + XCTAssertEqual(cursor, .init(line: 4, character: 0)) XCTAssertEqual(lines.joined(separator: ""), text) } @@ -343,7 +425,7 @@ final class AcceptSuggestionTests: XCTestCase { var extraInfo = SuggestionInjector.ExtraInfo() var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) + var cursor = CursorPosition(line: 5, character: 34) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, cursorPosition: &cursor, diff --git a/Tool/Tests/LangChainTests/TextSplitterTests/TextSplitterTests.swift b/Tool/Tests/LangChainTests/TextSplitterTests/TextSplitterTests.swift index 0a6c3739..13f2f88d 100644 --- a/Tool/Tests/LangChainTests/TextSplitterTests/TextSplitterTests.swift +++ b/Tool/Tests/LangChainTests/TextSplitterTests/TextSplitterTests.swift @@ -58,7 +58,7 @@ final class TextSplitterTests: XCTestCase { XCTAssertEqual( result, - ["Madam Speaker,", " Madam Vice", " President, our", " our First"] + ["Madam Speaker,", "Madam Vice", "President, our", "our First"] ) XCTAssertTrue(result.allSatisfy { $0.count <= 15 }) } diff --git a/Tool/Tests/LangChainTests/VectorStoreTests/TemporaryUSearchTests.swift b/Tool/Tests/LangChainTests/VectorStoreTests/TemporaryUSearchTests.swift index 132257b8..3736100d 100644 --- a/Tool/Tests/LangChainTests/VectorStoreTests/TemporaryUSearchTests.swift +++ b/Tool/Tests/LangChainTests/VectorStoreTests/TemporaryUSearchTests.swift @@ -1,4 +1,5 @@ import Foundation +import USearchIndex import XCTest import USearch @@ -6,21 +7,21 @@ import USearch @testable import LangChain class TemporaryUSearchTests: XCTestCase { - func test_usearch() { - let index = USearchIndex.make( + func test_usearch() async throws { + let index = USearchIndex( metric: USearchMetric.l2sq, dimensions: 4, connectivity: 8, quantization: USearchScalar.F32 ) - let vectorA: [Float32] = [0.3, 0.5, 1.2, 1.4] - let vectorB: [Float32] = [0.4, 0.2, 1.2, 1.1] - index.clear() - index.add(label: 42, vector: vectorA[...]) - index.add(label: 43, vector: vectorB[...]) + let vectorA: [Float] = [0.3, 0.5, 1.2, 1.4] + let vectorB: [Float] = [0.4, 0.2, 1.2, 1.1] + try await index.clear() + try await index.add(label: 42, vector: vectorA) + try await index.add(label: 43, vector: vectorB) - let results = index.search(vector: vectorA[...], count: 10) - assert(results.0[0] == 42) + let results = try await index.search(vector: vectorA, count: 10) + assert(results[0].label == 42) } func test_setting_data() async throws { diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift index 6df9abfe..6f6284ab 100644 --- a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift +++ b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift @@ -54,7 +54,7 @@ final class ChatGPTStreamTests: XCTestCase { .init(id: "1", role: .assistant, content: "hellomyfriends"), ], "History is not updated") - XCTAssertEqual(requestBody?.functions, [], "Function schema is not submitted") + XCTAssertEqual(requestBody?.functions, nil, "Function schema is not submitted") } func test_handling_function_call() async throws { @@ -128,7 +128,7 @@ final class ChatGPTStreamTests: XCTestCase { role: .function, content: "Function is called.", name: "function", - summary: "running" + summary: nil ), .init(id: "3", role: .assistant, content: "hellomyfriends"), ], "History is not updated") @@ -216,7 +216,7 @@ final class ChatGPTStreamTests: XCTestCase { role: .function, content: "Function is called.", name: "function", - summary: "running" + summary: nil ), .init( id: "3", @@ -229,7 +229,7 @@ final class ChatGPTStreamTests: XCTestCase { role: .function, content: "Function is called.", name: "function", - summary: "running" + summary: nil ), .init(id: "5", role: .assistant, content: "hellomyfriends"), ], "History is not updated") diff --git a/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift index 66d5e2b9..24ddb64c 100644 --- a/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift +++ b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift @@ -6,7 +6,7 @@ import XCTest final class AutoManagedChatGPTMemoryTests: XCTestCase { func test_send_all_messages_if_not_reached_token_limit() async { - let (messages, remainingTokens, memory) = await runService( + let (messages, _, memory) = await runService( systemPrompt: "system", messages: [ "hi", "hello", @@ -21,17 +21,17 @@ final class AutoManagedChatGPTMemoryTests: XCTestCase { "world", ]) - XCTAssertEqual(remainingTokens, 10000 - 12 - 6) +// XCTAssertEqual(remainingTokens, 10000 - 12 - 6) let history = await memory.history XCTAssertEqual(history.map(\.tokensCount), [ - 2, - 5, 5, + 8, + 8, ]) } func test_send_max_message_if_not_reached_token_limit() async { - let (messages, remainingTokens, _) = await runService( + let (messages, _, _) = await runService( systemPrompt: "system", messages: [ "hi", "hello", @@ -45,11 +45,11 @@ final class AutoManagedChatGPTMemoryTests: XCTestCase { "world", ], "Count from end to start.") - XCTAssertEqual(remainingTokens, 10000 - 10 - 6) +// XCTAssertEqual(remainingTokens, 10000 - 10 - 6) } func test_reached_token_limit() async { - let (messages, remainingTokens, _) = await runService( + let (messages, _, _) = await runService( systemPrompt: "system", messages: [ "hi", "hello", @@ -59,14 +59,13 @@ final class AutoManagedChatGPTMemoryTests: XCTestCase { ) XCTAssertEqual(messages, [ "system", - "world", ]) - XCTAssertEqual(remainingTokens, 201) +// XCTAssertEqual(remainingTokens, 201) } func test_minimum_reply_tokens_count() async { - let (messages, remainingTokens, _) = await runService( + let (messages, _, _) = await runService( systemPrompt: "system", messages: [ "hi", "hello", @@ -79,7 +78,7 @@ final class AutoManagedChatGPTMemoryTests: XCTestCase { "system", ]) - XCTAssertEqual(remainingTokens, 200) +// XCTAssertEqual(remainingTokens, 200) } }