diff --git a/Sources/linenoise/ControlCharacters.swift b/Sources/linenoise/ControlCharacters.swift index e905f06..1f1b759 100644 --- a/Sources/linenoise/ControlCharacters.swift +++ b/Sources/linenoise/ControlCharacters.swift @@ -52,6 +52,6 @@ internal enum ControlCharacters: UInt8 { case Backspace = 127 var character: Character { - return Character(UnicodeScalar(Int(self.rawValue))!) + return Character(UnicodeScalar(self.rawValue)) } } diff --git a/Sources/linenoise/EditState.swift b/Sources/linenoise/EditState.swift index 16b4e58..fe7e2da 100644 --- a/Sources/linenoise/EditState.swift +++ b/Sources/linenoise/EditState.swift @@ -30,12 +30,13 @@ import Foundation internal class EditState { - var buffer: String = "" - var location: String.Index + var buffer: [Character] = [] + var location: Int let prompt: String - public var currentBuffer: String { - return buffer + public var text: String { + get { buffer.string } + set { buffer = newValue.characters } } init(prompt: String) { @@ -44,7 +45,10 @@ internal class EditState { } var cursorPosition: Int { - return buffer.distance(from: buffer.startIndex, to: location) + // terminal seems to treat non-ASCII characters as if they take two positions. + buffer[buffer.startIndex.. Bool { - if location >= currentBuffer.endIndex || currentBuffer.isEmpty { + if location >= buffer.endIndex || buffer.isEmpty { return false } diff --git a/Sources/linenoise/linenoise.swift b/Sources/linenoise/linenoise.swift index 76b904e..9fd18c0 100644 --- a/Sources/linenoise/linenoise.swift +++ b/Sources/linenoise/linenoise.swift @@ -34,6 +34,23 @@ #endif import Foundation +extension StringProtocol { + var characters: [Character] { + self.map { $0 } + } +} + +extension ArraySlice { + var string: String { + self.reduce("") { $0 + String($1) } + } +} + +extension Array { + var string: String { + self.reduce("") { $0 + String($1) } + } +} public class LineNoise { public enum Mode { @@ -187,15 +204,30 @@ public class LineNoise { } // MARK: - Text input - internal func readCharacter(inputFile: Int32) -> UInt8? { - var input: UInt8 = 0 - let count = read(inputFile, &input, 1) - - if count == 0 { - return nil + internal func readCharacter(inputFile: Int32) -> Character? { + var data = Data() + var expectedCharacters = 1 + while expectedCharacters > 0 { + var input: UInt8 = 0 + let count = read(inputFile, &input, 1) + + if count == 0 { + return nil + } + + if data.isEmpty { + if input < 128 { + return Character(UnicodeScalar(input)) + } + // the first byte of a utf-8 sequence has a leading bit count that specifies + // the total number of bytes in the sequence. + expectedCharacters = (~input).leadingZeroBitCount + } + + data.append(input) + expectedCharacters -= 1 } - - return input + return String(data: data, encoding: .utf8).flatMap(Character.init) } // MARK: - Text output @@ -205,13 +237,15 @@ public class LineNoise { } internal func output(character: Character) throws { - if write(outputFile, String(character), 1) == -1 { + let bytes: [UInt8] = character.utf8.map { $0 } + if write(outputFile, bytes, bytes.count) == -1 { throw LinenoiseError.generalError("Unable to write to output") } } internal func output(text: String) throws { - if write(outputFile, text, text.count) == -1 { + let bytes = text.utf8.map { $0 } + if write(outputFile, bytes, bytes.count) == -1 { throw LinenoiseError.generalError("Unable to write to output") } } @@ -262,7 +296,7 @@ public class LineNoise { return nil } - var buf = [UInt8]() + var buf: [Character] = [] var i = 0 while true { @@ -272,7 +306,7 @@ public class LineNoise { return nil } - if buf[i] == 82 { // "R" + if buf[i] == "R" { // "R" break } @@ -280,14 +314,12 @@ public class LineNoise { } // Check the first characters are the escape code - if buf[0] != 0x1B || buf[1] != 0x5B { + if buf[0] != Character(UnicodeScalar(0x1B)) || buf[1] != Character(UnicodeScalar(0x5B)) { return nil } - let positionText = String(bytes: buf[2.. UInt8? { + internal func completeLine(editState: EditState) throws -> Character? { if completionCallback == nil { return nil } - let completions = completionCallback!(editState.currentBuffer) + let completions = completionCallback!(editState.text) if completions.count == 0 { try output(character: ControlCharacters.Bell.character) @@ -381,7 +413,7 @@ public class LineNoise { while true { if completionIndex < completions.count { try editState.withTemporaryState { - editState.buffer = completions[completionIndex] + editState.text = completions[completionIndex] _ = editState.moveEnd() try refreshLine(editState: editState) @@ -395,7 +427,7 @@ public class LineNoise { return nil } - switch char { + switch char.asciiValue { case ControlCharacters.Tab.rawValue: // Move to next completion completionIndex = (completionIndex + 1) % (completions.count + 1) @@ -413,7 +445,7 @@ public class LineNoise { default: // Update the buffer and return if completionIndex < completions.count { - editState.buffer = completions[completionIndex] + editState.text = completions[completionIndex] _ = editState.moveEnd() } @@ -428,19 +460,19 @@ public class LineNoise { // If we're at the end of history (editing the current line), // push it into a temporary buffer so it can be retreived later. if history.currentIndex == history.historyItems.count { - tempBuf = editState.currentBuffer + tempBuf = editState.text } else if preserveHistoryEdits { - history.replaceCurrent(editState.currentBuffer) + history.replaceCurrent(editState.text) } if let historyItem = history.navigateHistory(direction: direction) { - editState.buffer = historyItem + editState.text = historyItem _ = editState.moveEnd() try refreshLine(editState: editState) } else { if case .next = direction { - editState.buffer = tempBuf ?? "" + editState.buffer = tempBuf?.characters ?? [] _ = editState.moveEnd() try refreshLine(editState: editState) } else { @@ -455,13 +487,14 @@ public class LineNoise { if hintsCallback != nil { var cmdBuf = "" - let (hintOpt, color) = hintsCallback!(editState.buffer) + let (hintOpt, color) = hintsCallback!(editState.text) guard let hint = hintOpt else { return "" } - let currentLineLength = editState.prompt.count + editState.currentBuffer.count + // FIXME: non-ASCII characters may throw this off. + let currentLineLength = editState.prompt.count + editState.text.count let numCols = getNumCols() @@ -580,11 +613,11 @@ public class LineNoise { } } - internal func handleCharacter(_ char: UInt8, editState: EditState) throws -> String? { - switch char { + internal func handleCharacter(_ char: Character, editState: EditState) throws -> String? { + switch char.asciiValue { case ControlCharacters.Enter.rawValue: - return editState.currentBuffer + return editState.text case ControlCharacters.Ctrl_A.rawValue: try moveHome(editState: editState) @@ -603,7 +636,7 @@ public class LineNoise { // If there is a character at the right of the cursor, remove it // If the cursor is at the end of the line, act as EOF if !editState.eraseCharacterRight() { - if editState.currentBuffer.count == 0{ + if editState.buffer.count == 0{ throw LinenoiseError.EOF } else { try output(character: .Bell) @@ -634,7 +667,7 @@ public class LineNoise { case ControlCharacters.Ctrl_U.rawValue: // Delete whole line - editState.buffer = "" + editState.buffer = [] _ = editState.moveEnd() try refreshLine(editState: editState) @@ -666,7 +699,7 @@ public class LineNoise { default: // Insert character - try insertCharacter(Character(UnicodeScalar(char)), editState: editState) + try insertCharacter(char, editState: editState) try refreshLine(editState: editState) } @@ -683,7 +716,7 @@ public class LineNoise { return "" } - if char == ControlCharacters.Tab.rawValue && completionCallback != nil { + if char.asciiValue == ControlCharacters.Tab.rawValue && completionCallback != nil { if let completionChar = try completeLine(editState: editState) { char = completionChar } diff --git a/Tests/linenoiseTests/EditStateTests.swift b/Tests/linenoiseTests/EditStateTests.swift index a78be42..15b5919 100644 --- a/Tests/linenoiseTests/EditStateTests.swift +++ b/Tests/linenoiseTests/EditStateTests.swift @@ -35,27 +35,27 @@ class EditStateTests: XCTestCase { func testInitEmptyBuffer() { let s = EditState(prompt: "$ ") - expect(s.currentBuffer).to(equal("")) - expect(s.location).to(equal(s.currentBuffer.startIndex)) + expect(s.text).to(equal("")) + expect(s.location).to(equal(s.buffer.startIndex)) expect(s.prompt).to(equal("$ ")) } func testInsertCharacter() { let s = EditState(prompt: "") - s.insertCharacter("A"["A".startIndex]) + s.insertCharacter("A") - expect(s.buffer).to(equal("A")) - expect(s.location).to(equal(s.currentBuffer.endIndex)) + expect(s.text).to(equal("A")) + expect(s.location).to(equal(s.buffer.endIndex)) expect(s.cursorPosition).to(equal(1)) } func testBackspace() { let s = EditState(prompt: "") - s.insertCharacter("A"["A".startIndex]) + s.insertCharacter("A") expect(s.backspace()).to(beTrue()) - expect(s.currentBuffer).to(equal("")) - expect(s.location).to(equal(s.currentBuffer.startIndex)) + expect(s.text).to(equal("")) + expect(s.location).to(equal(s.buffer.startIndex)) // No more characters left, so backspace should return false expect(s.backspace()).to(beFalse()) @@ -63,32 +63,32 @@ class EditStateTests: XCTestCase { func testMoveLeft() { let s = EditState(prompt: "") - s.buffer = "Hello" - s.location = s.currentBuffer.endIndex + s.text = "Hello" + s.location = s.buffer.endIndex expect(s.moveLeft()).to(beTrue()) expect(s.cursorPosition).to(equal(4)) - s.location = s.currentBuffer.startIndex + s.location = s.buffer.startIndex expect(s.moveLeft()).to(beFalse()) } func testMoveRight() { let s = EditState(prompt: "") - s.buffer = "Hello" - s.location = s.currentBuffer.startIndex + s.text = "Hello" + s.location = s.buffer.startIndex expect(s.moveRight()).to(beTrue()) expect(s.cursorPosition).to(equal(1)) - s.location = s.currentBuffer.endIndex + s.location = s.buffer.endIndex expect(s.moveRight()).to(beFalse()) } func testMoveHome() { let s = EditState(prompt: "") - s.buffer = "Hello" - s.location = s.currentBuffer.endIndex + s.text = "Hello" + s.location = s.buffer.endIndex expect(s.moveHome()).to(beTrue()) expect(s.cursorPosition).to(equal(0)) @@ -98,8 +98,8 @@ class EditStateTests: XCTestCase { func testMoveEnd() { let s = EditState(prompt: "") - s.buffer = "Hello" - s.location = s.currentBuffer.startIndex + s.text = "Hello" + s.location = s.buffer.startIndex expect(s.moveEnd()).to(beTrue()) expect(s.cursorPosition).to(equal(5)) @@ -109,95 +109,95 @@ class EditStateTests: XCTestCase { func testRemovePreviousWord() { let s = EditState(prompt: "") - s.buffer = "Hello world" - s.location = s.currentBuffer.endIndex + s.text = "Hello world" + s.location = s.buffer.endIndex expect(s.deletePreviousWord()).to(beTrue()) - expect(s.buffer).to(equal("Hello ")) - expect(s.location).to(equal("Hello ".endIndex)) + expect(s.text).to(equal("Hello ")) + expect(s.location).to(equal(s.buffer.endIndex)) - s.buffer = "" - s.location = s.currentBuffer.endIndex + s.buffer = [] + s.location = s.buffer.endIndex expect(s.deletePreviousWord()).to(beFalse()) // Test with cursor location in the middle of the text - s.buffer = "This is a test" - s.location = s.currentBuffer.index(s.currentBuffer.startIndex, offsetBy: 8) + s.text = "This is a test" + s.location = s.buffer.index(s.buffer.startIndex, offsetBy: 8) expect(s.deletePreviousWord()).to(beTrue()) - expect(s.buffer).to(equal("This a test")) + expect(s.text).to(equal("This a test")) } func testDeleteToEndOfLine() { let s = EditState(prompt: "") - s.buffer = "Hello world" - s.location = s.currentBuffer.endIndex + s.text = "Hello world" + s.location = s.buffer.endIndex expect(s.deleteToEndOfLine()).to(beFalse()) - s.location = s.currentBuffer.index(s.currentBuffer.startIndex, offsetBy: 5) + s.location = s.buffer.index(s.buffer.startIndex, offsetBy: 5) expect(s.deleteToEndOfLine()).to(beTrue()) - expect(s.currentBuffer).to(equal("Hello")) + expect(s.text).to(equal("Hello")) } func testDeleteCharacter() { let s = EditState(prompt: "") - s.buffer = "Hello world" - s.location = s.currentBuffer.endIndex + s.text = "Hello world" + s.location = s.buffer.endIndex expect(s.deleteCharacter()).to(beFalse()) - s.location = s.currentBuffer.startIndex + s.location = s.buffer.startIndex expect(s.deleteCharacter()).to(beTrue()) - expect(s.currentBuffer).to(equal("ello world")) + expect(s.text).to(equal("ello world")) - s.location = s.currentBuffer.index(s.currentBuffer.startIndex, offsetBy: 5) + s.location = s.buffer.index(s.buffer.startIndex, offsetBy: 5) expect(s.deleteCharacter()).to(beTrue()) - expect(s.currentBuffer).to(equal("ello orld")) + expect(s.text).to(equal("ello orld")) } func testEraseCharacterRight() { let s = EditState(prompt: "") - s.buffer = "Hello" - s.location = s.currentBuffer.endIndex + s.text = "Hello" + s.location = s.buffer.endIndex expect(s.eraseCharacterRight()).to(beFalse()) - s.location = s.currentBuffer.startIndex + s.location = s.buffer.startIndex expect (s.eraseCharacterRight()).to(beTrue()) - expect(s.currentBuffer).to(equal("ello")) + expect(s.text).to(equal("ello")) // Test empty buffer - s.buffer = "" - s.location = s.currentBuffer.startIndex + s.buffer = [] + s.location = s.buffer.startIndex expect(s.eraseCharacterRight()).to(beFalse()) } func testSwapCharacters() { let s = EditState(prompt: "") - s.buffer = "Hello" - s.location = s.currentBuffer.endIndex + s.text = "Hello" + s.location = s.buffer.endIndex // Cursor at the end of the text expect(s.swapCharacterWithPrevious()).to(beTrue()) - expect(s.currentBuffer).to(equal("Helol")) - expect(s.location).to(equal(s.currentBuffer.endIndex)) + expect(s.text).to(equal("Helol")) + expect(s.location).to(equal(s.buffer.endIndex)) // Cursor in the middle of the text - s.location = s.currentBuffer.index(before: s.currentBuffer.endIndex) + s.location = s.buffer.index(before: s.buffer.endIndex) expect(s.swapCharacterWithPrevious()).to(beTrue()) - expect(s.currentBuffer).to(equal("Hello")) - expect(s.location).to(equal(s.currentBuffer.endIndex)) + expect(s.text).to(equal("Hello")) + expect(s.location).to(equal(s.buffer.endIndex)) // Cursor at the start of the text - s.location = s.currentBuffer.startIndex + s.location = s.buffer.startIndex expect(s.swapCharacterWithPrevious()).to(beTrue()) - expect(s.currentBuffer).to(equal("eHllo")) - expect(s.location).to(equal(s.currentBuffer.index(s.currentBuffer.startIndex, offsetBy: 2))) + expect(s.text).to(equal("eHllo")) + expect(s.location).to(equal(s.buffer.index(s.buffer.startIndex, offsetBy: 2))) } }