Skip to content

Commit 028d331

Browse files
committed
Merge tag 'fix-cursor-position-conversion' into develop
# Conflicts: # Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift # Pro
2 parents 65ba352 + 12e0844 commit 028d331

File tree

16 files changed

+846
-208
lines changed

16 files changed

+846
-208
lines changed

Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -222,15 +222,19 @@ extension OpenAIPromptToCodeService {
222222
func extractCodeAndDescription(from content: String)
223223
-> (code: String, description: String)
224224
{
225-
func extractCodeFromMarkdown(_ markdown: String) -> (code: String, endIndex: Int)? {
225+
func extractCodeFromMarkdown(
226+
_ markdown: String
227+
) -> (code: String, endIndex: String.Index)? {
226228
let codeBlockRegex = try! NSRegularExpression(
227229
pattern: #"```(?:\w+)?\R([\s\S]+?)\R```"#,
228230
options: .dotMatchesLineSeparators
229231
)
230232
let range = NSRange(markdown.startIndex..<markdown.endIndex, in: markdown)
231233
if let match = codeBlockRegex.firstMatch(in: markdown, options: [], range: range) {
232-
let codeBlockRange = Range(match.range(at: 1), in: markdown)!
233-
return (String(markdown[codeBlockRange]), match.range(at: 0).upperBound)
234+
guard let codeBlockRange = Range(match.range(at: 1), in: markdown),
235+
let endIndex = Range(match.range(at: 0), in: markdown)?.upperBound
236+
else { return nil }
237+
return (String(markdown[codeBlockRange]), endIndex)
234238
}
235239

236240
let incompleteCodeBlockRegex = try! NSRegularExpression(
@@ -243,8 +247,10 @@ extension OpenAIPromptToCodeService {
243247
options: [],
244248
range: range2
245249
) {
246-
let codeBlockRange = Range(match.range(at: 1), in: markdown)!
247-
return (String(markdown[codeBlockRange]), match.range(at: 0).upperBound)
250+
guard let codeBlockRange = Range(match.range(at: 1), in: markdown),
251+
let endIndex = Range(match.range(at: 0), in: markdown)?.upperBound
252+
else { return nil }
253+
return (String(markdown[codeBlockRange]), endIndex)
248254
}
249255
return nil
250256
}
@@ -253,8 +259,10 @@ extension OpenAIPromptToCodeService {
253259
return ("", "")
254260
}
255261

256-
func extractDescriptionFromMarkdown(_ markdown: String, startIndex: Int) -> String {
257-
let startIndex = markdown.index(markdown.startIndex, offsetBy: startIndex)
262+
func extractDescriptionFromMarkdown(
263+
_ markdown: String,
264+
startIndex: String.Index
265+
) -> String {
258266
guard startIndex < markdown.endIndex else { return "" }
259267
let range = startIndex..<markdown.endIndex
260268
let description = String(markdown[range])

Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift

Lines changed: 2 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ extension PseudoCommandHandler {
327327
// recover selection range
328328

329329
if let selection = result.newSelection {
330-
var range = convertCursorRangeToRange(selection, in: result.content)
330+
var range = SourceEditor.convertCursorRangeToRange(selection, in: result.content)
331331
if let value = AXValueCreate(.cfRange, &range) {
332332
AXUIElementSetAttributeValue(
333333
focusElement,
@@ -380,7 +380,7 @@ extension PseudoCommandHandler {
380380
guard let selectionRange = focusElement.selectedTextRange else { return nil }
381381
let content = focusElement.value
382382
let split = content.breakLines(appendLineBreakToLastLine: false)
383-
let range = convertRangeToCursorRange(selectionRange, in: content)
383+
let range = SourceEditor.convertRangeToCursorRange(selectionRange, in: content)
384384
return (content, split, [range], range.start, selectionRange.lowerBound)
385385
}
386386

@@ -426,55 +426,5 @@ extension PseudoCommandHandler {
426426
usesTabsForIndentation: usesTabsForIndentation
427427
)
428428
}
429-
430-
func convertCursorRangeToRange(
431-
_ cursorRange: CursorRange,
432-
in content: String
433-
) -> CFRange {
434-
let lines = content.breakLines()
435-
var countS = 0
436-
var countE = 0
437-
var range = CFRange(location: 0, length: 0)
438-
for (i, line) in lines.enumerated() {
439-
if i == cursorRange.start.line {
440-
countS = countS + cursorRange.start.character
441-
range.location = countS
442-
}
443-
if i == cursorRange.end.line {
444-
countE = countE + cursorRange.end.character
445-
range.length = max(countE - range.location, 0)
446-
break
447-
}
448-
countS += line.count
449-
countE += line.count
450-
}
451-
return range
452-
}
453-
454-
func convertRangeToCursorRange(
455-
_ range: ClosedRange<Int>,
456-
in content: String
457-
) -> CursorRange {
458-
let lines = content.breakLines()
459-
guard !lines.isEmpty else { return CursorRange(start: .zero, end: .zero) }
460-
var countS = 0
461-
var countE = 0
462-
var cursorRange = CursorRange(start: .zero, end: .outOfScope)
463-
for (i, line) in lines.enumerated() {
464-
if countS <= range.lowerBound, range.lowerBound < countS + line.count {
465-
cursorRange.start = .init(line: i, character: range.lowerBound - countS)
466-
}
467-
if countE <= range.upperBound, range.upperBound < countE + line.count {
468-
cursorRange.end = .init(line: i, character: range.upperBound - countE)
469-
break
470-
}
471-
countS += line.count
472-
countE += line.count
473-
}
474-
if cursorRange.end == .outOfScope {
475-
cursorRange.end = .init(line: lines.endIndex - 1, character: lines.last?.count ?? 0)
476-
}
477-
return cursorRange
478-
}
479429
}
480430

Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -372,12 +372,14 @@ extension WindowBaseCommandHandler {
372372
return false
373373
}
374374
let line = editor.lines[selection.start.line]
375-
guard selection.start.character > 0, selection.start.character < line.count else {
376-
return false
377-
}
378-
let substring =
379-
line[line.startIndex..<line
380-
.index(line.startIndex, offsetBy: selection.start.character)]
375+
guard selection.start.character > 0,
376+
selection.start.character < line.utf16.count
377+
else { return false }
378+
let substring = line[line.utf16.startIndex..<(line.index(
379+
line.utf16.startIndex,
380+
offsetBy: selection.start.character,
381+
limitedBy: line.utf16.endIndex
382+
) ?? line.utf16.endIndex)]
381383
return substring.allSatisfy { $0.isWhitespace }
382384
}()
383385

@@ -398,13 +400,13 @@ extension WindowBaseCommandHandler {
398400
let viewStore = Service.shared.guiController.viewStore
399401

400402
let customCommandTemplateProcessor = CustomCommandTemplateProcessor()
401-
403+
402404
let newExtraSystemPrompt: String? = if let extraSystemPrompt {
403405
await customCommandTemplateProcessor.process(extraSystemPrompt)
404406
} else {
405407
nil
406408
}
407-
409+
408410
let newPrompt: String? = if let prompt {
409411
await customCommandTemplateProcessor.process(prompt)
410412
} else {

Core/Sources/SuggestionInjector/SuggestionInjector.swift

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,12 @@ public struct SuggestionInjector {
5454
start.character < firstRemovedLine.count,
5555
!toBeInserted.isEmpty
5656
{
57-
let leftoverRange = firstRemovedLine.startIndex..<(firstRemovedLine.index(
58-
firstRemovedLine.startIndex,
57+
let leftoverRange = firstRemovedLine.utf16.startIndex..<(firstRemovedLine.utf16.index(
58+
firstRemovedLine.utf16.startIndex,
5959
offsetBy: start.character,
60-
limitedBy: firstRemovedLine.endIndex
61-
) ?? firstRemovedLine.endIndex)
62-
var leftover = firstRemovedLine[leftoverRange]
60+
limitedBy: firstRemovedLine.utf16.endIndex
61+
) ?? firstRemovedLine.utf16.endIndex)
62+
var leftover = String(firstRemovedLine[leftoverRange])
6363
if leftover.last?.isNewline ?? false {
6464
leftover.removeLast(1)
6565
}
@@ -76,7 +76,8 @@ public struct SuggestionInjector {
7676
lineEnding: lineEnding
7777
)
7878

79-
let cursorCol = toBeInserted[toBeInserted.endIndex - 1].count - 1 - recoveredSuffixLength
79+
let cursorCol = toBeInserted[toBeInserted.endIndex - 1].utf16.count
80+
- 1 - recoveredSuffixLength
8081
let insertingIndex = min(start.line, content.endIndex)
8182
content.insert(contentsOf: toBeInserted, at: insertingIndex)
8283
extraInfo.modifications.append(.inserted(insertingIndex, toBeInserted))
@@ -98,7 +99,8 @@ public struct SuggestionInjector {
9899
let lastRemovedLineCleaned = lastRemovedLine.droppedLineBreak()
99100

100101
// If the replaced range covers the whole line, return immediately.
101-
guard end.character >= 0, end.character - 1 < lastRemovedLineCleaned.count else { return 0 }
102+
guard end.character >= 0, end.character - 1 < lastRemovedLineCleaned.utf16.count
103+
else { return 0 }
102104

103105
// if we are not inserting anything, return immediately.
104106
guard !toBeInserted.isEmpty,
@@ -117,12 +119,13 @@ public struct SuggestionInjector {
117119
// locate the split index, the prefix of which matches the suggestion prefix.
118120
var splitIndex: String.Index?
119121

120-
for offset in end.character..<lastRemovedLineCleaned.count {
121-
let proposedIndex = lastRemovedLineCleaned.index(
122-
lastRemovedLineCleaned.startIndex,
123-
offsetBy: offset
124-
)
125-
let prefix = lastRemovedLineCleaned[..<proposedIndex]
122+
for offset in end.character..<lastRemovedLineCleaned.utf16.count {
123+
let proposedIndex = lastRemovedLineCleaned.utf16.index(
124+
lastRemovedLineCleaned.utf16.startIndex,
125+
offsetBy: offset,
126+
limitedBy: lastRemovedLineCleaned.utf16.endIndex
127+
) ?? lastRemovedLineCleaned.utf16.endIndex
128+
let prefix = String(lastRemovedLineCleaned[..<proposedIndex])
126129
if first.hasPrefix(prefix) {
127130
splitIndex = proposedIndex
128131
}
@@ -156,7 +159,7 @@ public struct SuggestionInjector {
156159

157160
toBeInserted[toBeInserted.endIndex - 1] = lastInsertingLine
158161

159-
return suffix.count
162+
return suffix.utf16.count
160163
}
161164
}
162165

Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,53 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
5555
let suggestion = filespace.presentingSuggestion
5656
XCTAssertNotNil(suggestion)
5757
}
58+
59+
func test_text_typing_suggestion_with_emoji_in_the_middle_should_be_valid() async throws {
60+
let filespace = try await prepare(
61+
suggestionText: "hello🎆🎆 man",
62+
cursorPosition: .init(line: 1, character: 0),
63+
range: .init(startPair: (1, 0), endPair: (1, 0))
64+
)
65+
let isValid = await filespace.validateSuggestions(
66+
lines: ["\n", "hell🎆🎆 man\n", "\n"],
67+
cursorPosition: .init(line: 1, character: 4)
68+
)
69+
XCTAssertTrue(isValid)
70+
let suggestion = filespace.presentingSuggestion
71+
XCTAssertNotNil(suggestion)
72+
}
73+
74+
func test_text_typing_suggestion_typed_emoji_in_the_middle_should_be_valid() async throws {
75+
let filespace = try await prepare(
76+
suggestionText: "h🎆🎆o man",
77+
cursorPosition: .init(line: 1, character: 0),
78+
range: .init(startPair: (1, 0), endPair: (1, 0))
79+
)
80+
let isValid = await filespace.validateSuggestions(
81+
lines: ["\n", "h🎆🎆o man\n", "\n"],
82+
cursorPosition: .init(line: 1, character: 2)
83+
)
84+
XCTAssertTrue(isValid)
85+
let suggestion = filespace.presentingSuggestion
86+
XCTAssertNotNil(suggestion)
87+
}
88+
89+
func test_text_typing_suggestion_cutting_emoji_in_the_middle_should_be_valid() async throws {
90+
// undefined behavior, must not crash
91+
92+
let filespace = try await prepare(
93+
suggestionText: "h🎆🎆o man",
94+
cursorPosition: .init(line: 1, character: 0),
95+
range: .init(startPair: (1, 0), endPair: (1, 0))
96+
)
97+
let isValid = await filespace.validateSuggestions(
98+
lines: ["\n", "h🎆🎆o man\n", "\n"],
99+
cursorPosition: .init(line: 1, character: 3)
100+
)
101+
XCTAssertTrue(isValid)
102+
let suggestion = filespace.presentingSuggestion
103+
XCTAssertNotNil(suggestion)
104+
}
58105

59106
func test_text_cursor_moved_to_another_line_should_invalidate() async throws {
60107
let filespace = try await prepare(
@@ -122,10 +169,35 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
122169
cursorPosition: .init(line: 1, character: 0),
123170
range: .init(startPair: (1, 0), endPair: (1, 0))
124171
)
172+
let wasValid = await filespace.validateSuggestions(
173+
lines: ["\n", "hello man\n", "\n"],
174+
cursorPosition: .init(line: 1, character: 8)
175+
)
125176
let isValid = await filespace.validateSuggestions(
126177
lines: ["\n", "hello man\n", "\n"],
127178
cursorPosition: .init(line: 1, character: 9)
128179
)
180+
XCTAssertTrue(wasValid)
181+
XCTAssertFalse(isValid)
182+
let suggestion = filespace.presentingSuggestion
183+
XCTAssertNil(suggestion)
184+
}
185+
186+
func test_finish_typing_the_whole_single_line_suggestion_with_emoji_should_invalidate() async throws {
187+
let filespace = try await prepare(
188+
suggestionText: "hello m🎆🎆an",
189+
cursorPosition: .init(line: 1, character: 0),
190+
range: .init(startPair: (1, 0), endPair: (1, 0))
191+
)
192+
let wasValid = await filespace.validateSuggestions(
193+
lines: ["\n", "hello m🎆🎆an\n", "\n"],
194+
cursorPosition: .init(line: 1, character: 12)
195+
)
196+
let isValid = await filespace.validateSuggestions(
197+
lines: ["\n", "hello m🎆🎆an\n", "\n"],
198+
cursorPosition: .init(line: 1, character: 13)
199+
)
200+
XCTAssertTrue(wasValid)
129201
XCTAssertFalse(isValid)
130202
let suggestion = filespace.presentingSuggestion
131203
XCTAssertNil(suggestion)
@@ -138,10 +210,15 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
138210
cursorPosition: .init(line: 1, character: 0),
139211
range: .init(startPair: (1, 0), endPair: (1, 0))
140212
)
213+
let wasValid = await filespace.validateSuggestions(
214+
lines: ["\n", "hello man!!!!!\n", "\n"],
215+
cursorPosition: .init(line: 1, character: 8)
216+
)
141217
let isValid = await filespace.validateSuggestions(
142218
lines: ["\n", "hello man!!!!!\n", "\n"],
143219
cursorPosition: .init(line: 1, character: 9)
144220
)
221+
XCTAssertTrue(wasValid)
145222
XCTAssertFalse(isValid)
146223
let suggestion = filespace.presentingSuggestion
147224
XCTAssertNil(suggestion)
@@ -161,6 +238,21 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
161238
let suggestion = filespace.presentingSuggestion
162239
XCTAssertNotNil(suggestion)
163240
}
241+
242+
func test_finish_typing_the_whole_multiple_line_suggestion_with_emoji_should_be_valid() async throws {
243+
let filespace = try await prepare(
244+
suggestionText: "hello m🎆🎆an\nhow are you?",
245+
cursorPosition: .init(line: 1, character: 0),
246+
range: .init(startPair: (1, 0), endPair: (1, 0))
247+
)
248+
let isValid = await filespace.validateSuggestions(
249+
lines: ["\n", "hello m🎆🎆an\n", "\n"],
250+
cursorPosition: .init(line: 1, character: 13)
251+
)
252+
XCTAssertTrue(isValid)
253+
let suggestion = filespace.presentingSuggestion
254+
XCTAssertNotNil(suggestion)
255+
}
164256

165257
func test_undo_text_to_a_state_before_the_suggestion_was_generated_should_invalidate(
166258
) async throws {

Core/Tests/ServiceTests/PseudoCommandHandlerFileProcessingTests.swift

Lines changed: 0 additions & 43 deletions
This file was deleted.

0 commit comments

Comments
 (0)