Skip to content

Commit 69b1b31

Browse files
committed
Recover line ending when possible
1 parent b654bed commit 69b1b31

File tree

18 files changed

+581
-616
lines changed

18 files changed

+581
-616
lines changed

Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ extension PseudoCommandHandler {
316316
else { return nil }
317317
guard let selectionRange = focusElement.selectedTextRange else { return nil }
318318
let content = focusElement.value
319-
let split = content.breakLines()
319+
let split = content.breakLines(appendLineBreakToLastLine: false)
320320
let range = convertRangeToCursorRange(selectionRange, in: content)
321321
return (content, split, [range], range.start)
322322
}
@@ -409,20 +409,3 @@ extension PseudoCommandHandler {
409409
return cursorRange
410410
}
411411
}
412-
413-
public extension String {
414-
/// Break a string into lines.
415-
func breakLines() -> [String] {
416-
let lines = split(separator: "\n", omittingEmptySubsequences: false)
417-
var all = [String]()
418-
for (index, line) in lines.enumerated() {
419-
if index == lines.endIndex - 1 {
420-
all.append(String(line))
421-
} else {
422-
all.append(String(line) + "\n")
423-
}
424-
}
425-
return all
426-
}
427-
}
428-

Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler {
267267
filespace.codeMetadata.tabSize = editor.tabSize
268268
filespace.codeMetadata.indentSize = editor.indentSize
269269
filespace.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation
270+
filespace.codeMetadata.guessLineEnding(from: editor.lines.first)
270271
return nil
271272
}
272273

Core/Sources/SuggestionInjector/SuggestionInjector.swift

Lines changed: 20 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import Foundation
22
import SuggestionModel
33

4-
let suggestionStart = "/*========== Copilot Suggestion"
5-
let suggestionEnd = "*///======== End of Copilot Suggestion"
6-
74
// NOTE: Every lines from Xcode Extension has a line break at its end, even the last line.
85
// NOTE: Copilot's completion always start at character 0, no matter where the cursor is.
96

@@ -18,116 +15,6 @@ public struct SuggestionInjector {
1815
public init() {}
1916
}
2017

21-
public func rejectCurrentSuggestions(
22-
from content: inout [String],
23-
cursorPosition: inout CursorPosition,
24-
extraInfo: inout ExtraInfo
25-
) {
26-
var ranges = [ClosedRange<Int>]()
27-
var suggestionStartIndex = -1
28-
29-
// find ranges of suggestion comments
30-
for (index, line) in content.enumerated() {
31-
if line.hasPrefix(suggestionStart) {
32-
suggestionStartIndex = index
33-
}
34-
if suggestionStartIndex >= 0, line.hasPrefix(suggestionEnd) {
35-
ranges.append(.init(uncheckedBounds: (suggestionStartIndex, index)))
36-
suggestionStartIndex = -1
37-
}
38-
}
39-
40-
let reversedRanges = ranges.reversed()
41-
42-
extraInfo.modifications.append(contentsOf: reversedRanges.map(Modification.deleted))
43-
extraInfo.didChangeContent = !ranges.isEmpty
44-
45-
// remove the lines from bottom to top
46-
for range in reversedRanges {
47-
for i in stride(from: range.upperBound, through: range.lowerBound, by: -1) {
48-
if i <= cursorPosition.line, cursorPosition.line >= 0 {
49-
cursorPosition = .init(
50-
line: cursorPosition.line - 1,
51-
character: i == cursorPosition.line ? 0 : cursorPosition.character
52-
)
53-
extraInfo.didChangeCursorPosition = true
54-
}
55-
content.remove(at: i)
56-
}
57-
}
58-
59-
extraInfo.suggestionRange = nil
60-
}
61-
62-
public func proposeSuggestion(
63-
intoContentWithoutSuggestion content: inout [String],
64-
completion: CodeSuggestion,
65-
index: Int,
66-
count: Int,
67-
extraInfo: inout ExtraInfo
68-
) {
69-
// assemble suggestion comment
70-
let start = completion.range.start
71-
let startText = "\(suggestionStart) \(index + 1)/\(count)"
72-
var lines = [startText + "\n"]
73-
lines.append(contentsOf: completion.text.breakLines(appendLineBreakToLastLine: true))
74-
lines.append(suggestionEnd + "\n")
75-
76-
// if suggestion is empty, returns without modifying the code
77-
guard lines.count > 2 else { return }
78-
79-
// replace the common prefix of the first line with space and carrot
80-
let existedLine = start.line < content.endIndex ? content[start.line] : nil
81-
let commonPrefix = longestCommonPrefix(of: lines[1], and: existedLine ?? "")
82-
83-
if !commonPrefix.isEmpty {
84-
let replacingText = {
85-
switch (commonPrefix.hasSuffix("\n"), commonPrefix.count) {
86-
case (false, let count):
87-
return String(repeating: " ", count: count - 1) + "^"
88-
case (true, let count) where count > 1:
89-
return String(repeating: " ", count: count - 2) + "^\n"
90-
case (true, _):
91-
return "\n"
92-
}
93-
}()
94-
95-
lines[1].replaceSubrange(
96-
lines[1].startIndex..<(
97-
lines[1].index(
98-
lines[1].startIndex,
99-
offsetBy: commonPrefix.count,
100-
limitedBy: lines[1].endIndex
101-
) ?? lines[1].endIndex
102-
),
103-
with: replacingText
104-
)
105-
}
106-
107-
// if the suggestion is only appending new lines and spaces, return without modification
108-
if completion.text.dropFirst(commonPrefix.count)
109-
.allSatisfy({ $0.isWhitespace || $0.isNewline }) { return }
110-
111-
// determine if it's inserted to the current line or the next line
112-
let lineIndex = start.line + {
113-
guard let existedLine else { return 0 }
114-
if existedLine.isEmptyOrNewLine { return 1 }
115-
if commonPrefix.isEmpty { return 0 }
116-
return 1
117-
}()
118-
if content.endIndex < lineIndex {
119-
extraInfo.didChangeContent = true
120-
extraInfo.suggestionRange = content.endIndex...content.endIndex + lines.count - 1
121-
extraInfo.modifications.append(.inserted(content.endIndex, lines))
122-
content.append(contentsOf: lines)
123-
} else {
124-
extraInfo.didChangeContent = true
125-
extraInfo.suggestionRange = lineIndex...lineIndex + lines.count - 1
126-
extraInfo.modifications.append(.inserted(lineIndex, lines))
127-
content.insert(contentsOf: lines, at: lineIndex)
128-
}
129-
}
130-
13118
public func acceptSuggestion(
13219
intoContentWithoutSuggestion content: inout [String],
13320
cursorPosition: inout CursorPosition,
@@ -140,6 +27,11 @@ public struct SuggestionInjector {
14027
let start = completion.range.start
14128
let end = completion.range.end
14229
let suggestionContent = completion.text
30+
let lineEnding = if let ending = content.first?.last, ending.isNewline {
31+
String(ending)
32+
} else {
33+
"\n"
34+
}
14335

14436
let firstRemovedLine = content[safe: start.line]
14537
let lastRemovedLine = content[safe: end.line]
@@ -150,7 +42,10 @@ public struct SuggestionInjector {
15042
content.removeSubrange(startLine...endLine)
15143
}
15244

153-
var toBeInserted = suggestionContent.breakLines(appendLineBreakToLastLine: true)
45+
var toBeInserted = suggestionContent.breakLines(
46+
proposedLineEnding: lineEnding,
47+
appendLineBreakToLastLine: true
48+
)
15449

15550
// prepending prefix text not in range if needed.
15651
if let firstRemovedLine,
@@ -165,7 +60,7 @@ public struct SuggestionInjector {
16560
limitedBy: firstRemovedLine.endIndex
16661
) ?? firstRemovedLine.endIndex)
16762
var leftover = firstRemovedLine[leftoverRange]
168-
if leftover.hasSuffix("\n") {
63+
if leftover.last?.isNewline ?? false {
16964
leftover.removeLast(1)
17065
}
17166
toBeInserted[0].insert(
@@ -177,7 +72,8 @@ public struct SuggestionInjector {
17772
let recoveredSuffixLength = recoverSuffixIfNeeded(
17873
endOfReplacedContent: end,
17974
toBeInserted: &toBeInserted,
180-
lastRemovedLine: lastRemovedLine
75+
lastRemovedLine: lastRemovedLine,
76+
lineEnding: lineEnding
18177
)
18278

18379
let cursorCol = toBeInserted[toBeInserted.endIndex - 1].count - 1 - recoveredSuffixLength
@@ -193,7 +89,8 @@ public struct SuggestionInjector {
19389
func recoverSuffixIfNeeded(
19490
endOfReplacedContent end: CursorPosition,
19591
toBeInserted: inout [String],
196-
lastRemovedLine: String?
92+
lastRemovedLine: String?,
93+
lineEnding: String
19794
) -> Int {
19895
// If there is no line removed, there is no need to recover anything.
19996
guard let lastRemovedLine, !lastRemovedLine.isEmptyOrNewLine else { return 0 }
@@ -255,7 +152,7 @@ public struct SuggestionInjector {
255152
let lastInsertingLine = toBeInserted[toBeInserted.endIndex - 1]
256153
.droppedLineBreak()
257154
.appending(suffix)
258-
.recoveredLineBreak()
155+
.recoveredLineBreak(lineEnding: lineEnding)
259156

260157
toBeInserted[toBeInserted.endIndex - 1] = lastInsertingLine
261158

@@ -280,36 +177,22 @@ public struct SuggestionAnalyzer {
280177
}
281178

282179
extension String {
283-
/// Break a string into lines.
284-
func breakLines(appendLineBreakToLastLine: Bool = false) -> [String] {
285-
let lines = split(separator: "\n", omittingEmptySubsequences: false)
286-
var all = [String]()
287-
for (index, line) in lines.enumerated() {
288-
if !appendLineBreakToLastLine, index == lines.endIndex - 1 {
289-
all.append(String(line))
290-
} else {
291-
all.append(String(line) + "\n")
292-
}
293-
}
294-
return all
295-
}
296-
297180
var isEmptyOrNewLine: Bool {
298-
isEmpty || self == "\n"
181+
isEmpty || self == "\n" || self == "\r\n" || self == "\r"
299182
}
300183

301184
func droppedLineBreak() -> String {
302-
if hasSuffix("\n") {
185+
if last?.isNewline ?? false {
303186
return String(dropLast(1))
304187
}
305188
return self
306189
}
307190

308-
func recoveredLineBreak() -> String {
309-
if hasSuffix("\n") {
191+
func recoveredLineBreak(lineEnding: String) -> String {
192+
if hasSuffix(lineEnding) {
310193
return self
311194
}
312-
return self + "\n"
195+
return self + lineEnding
313196
}
314197
}
315198

Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ import Foundation
22
import SuggestionModel
33
import XCTest
44

5-
@testable import Workspace
65
@testable import Service
6+
@testable import Workspace
77

88
class FilespaceSuggestionInvalidationTests: XCTestCase {
99
@WorkspaceActor
10-
func prepare(suggestionText: String, cursorPosition: CursorPosition) async throws -> Filespace {
10+
func prepare(
11+
suggestionText: String,
12+
cursorPosition: CursorPosition,
13+
range: CursorRange
14+
) async throws -> Filespace {
1115
let pool = WorkspacePool()
1216
let (_, filespace) = try await pool
1317
.fetchOrCreateWorkspaceAndFilespace(fileURL: URL(fileURLWithPath: "file/path/to.swift"))
@@ -16,7 +20,7 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
1620
id: "",
1721
text: suggestionText,
1822
position: cursorPosition,
19-
range: .outOfScope
23+
range: range
2024
),
2125
]
2226
return filespace
@@ -25,7 +29,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
2529
func test_text_typing_suggestion_should_be_valid() async throws {
2630
let filespace = try await prepare(
2731
suggestionText: "hello man",
28-
cursorPosition: .init(line: 1, character: 0)
32+
cursorPosition: .init(line: 1, character: 0),
33+
range: .init(startPair: (1, 0), endPair: (1, 0))
2934
)
3035
let isValid = await filespace.validateSuggestions(
3136
lines: ["\n", "hell\n", "\n"],
@@ -39,7 +44,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
3944
func test_text_typing_suggestion_in_the_middle_should_be_valid() async throws {
4045
let filespace = try await prepare(
4146
suggestionText: "hello man",
42-
cursorPosition: .init(line: 1, character: 0)
47+
cursorPosition: .init(line: 1, character: 0),
48+
range: .init(startPair: (1, 0), endPair: (1, 0))
4349
)
4450
let isValid = await filespace.validateSuggestions(
4551
lines: ["\n", "hell man\n", "\n"],
@@ -53,7 +59,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
5359
func test_text_cursor_moved_to_another_line_should_invalidate() async throws {
5460
let filespace = try await prepare(
5561
suggestionText: "hello man",
56-
cursorPosition: .init(line: 1, character: 0)
62+
cursorPosition: .init(line: 1, character: 0),
63+
range: .init(startPair: (1, 0), endPair: (1, 0))
5764
)
5865
let isValid = await filespace.validateSuggestions(
5966
lines: ["\n", "hell\n", "\n"],
@@ -67,7 +74,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
6774
func test_text_cursor_is_invalid_should_invalidate() async throws {
6875
let filespace = try await prepare(
6976
suggestionText: "hello man",
70-
cursorPosition: .init(line: 100, character: 0)
77+
cursorPosition: .init(line: 100, character: 0),
78+
range: .init(startPair: (1, 0), endPair: (1, 0))
7179
)
7280
let isValid = await filespace.validateSuggestions(
7381
lines: ["\n", "hell\n", "\n"],
@@ -81,7 +89,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
8189
func test_line_content_does_not_match_input_should_invalidate() async throws {
8290
let filespace = try await prepare(
8391
suggestionText: "hello man",
84-
cursorPosition: .init(line: 1, character: 0)
92+
cursorPosition: .init(line: 1, character: 0),
93+
range: .init(startPair: (1, 0), endPair: (1, 0))
8594
)
8695
let isValid = await filespace.validateSuggestions(
8796
lines: ["\n", "helo\n", "\n"],
@@ -95,7 +104,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
95104
func test_line_content_does_not_match_input_should_invalidate_index_out_of_scope() async throws {
96105
let filespace = try await prepare(
97106
suggestionText: "hello man",
98-
cursorPosition: .init(line: 1, character: 0)
107+
cursorPosition: .init(line: 1, character: 0),
108+
range: .init(startPair: (1, 0), endPair: (1, 0))
99109
)
100110
let isValid = await filespace.validateSuggestions(
101111
lines: ["\n", "helo\n", "\n"],
@@ -109,7 +119,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
109119
func test_finish_typing_the_whole_single_line_suggestion_should_invalidate() async throws {
110120
let filespace = try await prepare(
111121
suggestionText: "hello man",
112-
cursorPosition: .init(line: 1, character: 0)
122+
cursorPosition: .init(line: 1, character: 0),
123+
range: .init(startPair: (1, 0), endPair: (1, 0))
113124
)
114125
let isValid = await filespace.validateSuggestions(
115126
lines: ["\n", "hello man\n", "\n"],
@@ -124,7 +135,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
124135
) async throws {
125136
let filespace = try await prepare(
126137
suggestionText: "hello man",
127-
cursorPosition: .init(line: 1, character: 0)
138+
cursorPosition: .init(line: 1, character: 0),
139+
range: .init(startPair: (1, 0), endPair: (1, 0))
128140
)
129141
let isValid = await filespace.validateSuggestions(
130142
lines: ["\n", "hello man!!!!!\n", "\n"],
@@ -138,7 +150,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
138150
func test_finish_typing_the_whole_multiple_line_suggestion_should_be_valid() async throws {
139151
let filespace = try await prepare(
140152
suggestionText: "hello man\nhow are you?",
141-
cursorPosition: .init(line: 1, character: 0)
153+
cursorPosition: .init(line: 1, character: 0),
154+
range: .init(startPair: (1, 0), endPair: (1, 0))
142155
)
143156
let isValid = await filespace.validateSuggestions(
144157
lines: ["\n", "hello man\n", "\n"],
@@ -153,7 +166,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
153166
) async throws {
154167
let filespace = try await prepare(
155168
suggestionText: "hello man",
156-
cursorPosition: .init(line: 1, character: 5) // generating man from hello
169+
cursorPosition: .init(line: 1, character: 5), // generating man from hello
170+
range: .init(startPair: (1, 0), endPair: (1, 5))
157171
)
158172
let isValid = await filespace.validateSuggestions(
159173
lines: ["\n", "hell\n", "\n"],

0 commit comments

Comments
 (0)