Skip to content

Commit 12e0844

Browse files
committed
Merge branch 'hotfix/fix-cursor-position-conversion'
2 parents 59ba332 + 9d3b049 commit 12e0844

15 files changed

Lines changed: 845 additions & 207 deletions

File tree

Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift

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

235239
let incompleteCodeBlockRegex = try! NSRegularExpression(
@@ -242,8 +246,10 @@ extension OpenAIPromptToCodeService {
242246
options: [],
243247
range: range2
244248
) {
245-
let codeBlockRange = Range(match.range(at: 1), in: markdown)!
246-
return (String(markdown[codeBlockRange]), match.range(at: 0).upperBound)
249+
guard let codeBlockRange = Range(match.range(at: 1), in: markdown),
250+
let endIndex = Range(match.range(at: 0), in: markdown)?.upperBound
251+
else { return nil }
252+
return (String(markdown[codeBlockRange]), endIndex)
247253
}
248254
return nil
249255
}
@@ -252,8 +258,10 @@ extension OpenAIPromptToCodeService {
252258
return ("", "")
253259
}
254260

255-
func extractDescriptionFromMarkdown(_ markdown: String, startIndex: Int) -> String {
256-
let startIndex = markdown.index(markdown.startIndex, offsetBy: startIndex)
261+
func extractDescriptionFromMarkdown(
262+
_ markdown: String,
263+
startIndex: String.Index
264+
) -> String {
257265
guard startIndex < markdown.endIndex else { return "" }
258266
let range = startIndex..<markdown.endIndex
259267
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
@@ -321,7 +321,7 @@ extension PseudoCommandHandler {
321321
// recover selection range
322322

323323
if let selection = result.newSelection {
324-
var range = convertCursorRangeToRange(selection, in: result.content)
324+
var range = SourceEditor.convertCursorRangeToRange(selection, in: result.content)
325325
if let value = AXValueCreate(.cfRange, &range) {
326326
AXUIElementSetAttributeValue(
327327
focusElement,
@@ -373,7 +373,7 @@ extension PseudoCommandHandler {
373373
guard let selectionRange = focusElement.selectedTextRange else { return nil }
374374
let content = focusElement.value
375375
let split = content.breakLines(appendLineBreakToLastLine: false)
376-
let range = convertRangeToCursorRange(selectionRange, in: content)
376+
let range = SourceEditor.convertRangeToCursorRange(selectionRange, in: content)
377377
return (content, split, [range], range.start)
378378
}
379379

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

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)