├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources ├── linenoise │ ├── AnsiCodes.swift │ ├── ControlCharacters.swift │ ├── EditState.swift │ ├── Errors.swift │ ├── History.swift │ ├── Terminal.swift │ └── linenoise.swift └── linenoiseDemo │ └── main.swift ├── Tests ├── LinuxMain.swift └── linenoiseTests │ ├── AnsiCodesTests.swift │ ├── EditStateTests.swift │ └── HistoryTests.swift └── images ├── completion.gif └── hints.gif /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | xcuserdata/ 3 | 4 | .build/ 5 | LineNoise.xcodeproj/ 6 | Package.resolved 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Andy Best 2 | Copyright (c) 2010-2014, Salvatore Sanfilippo 3 | Copyright (c) 2010-2013, Pieter Noordhuis 4 | 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, 11 | this list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 21 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 24 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:4.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | 5 | import PackageDescription 6 | 7 | let package = Package( 8 | name: "LineNoise", 9 | products: [ 10 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 11 | .library( 12 | name: "LineNoise", 13 | targets: ["LineNoise"]), 14 | .executable( 15 | name: "linenoiseDemo", 16 | targets: ["linenoiseDemo"]), 17 | ], 18 | dependencies: [ 19 | .package(url: "https://github.com/Quick/Nimble.git", from: "8.0.0") 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 24 | .target( 25 | name: "LineNoise", 26 | dependencies: [], 27 | path: "Sources/linenoise"), 28 | .target( 29 | name: "linenoiseDemo", 30 | dependencies: ["LineNoise"]), 31 | .testTarget( 32 | name: "linenoiseTests", 33 | dependencies: ["LineNoise", "Nimble"]), 34 | ] 35 | ) 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Linenoise-Swift 2 | 3 | A pure Swift implementation of the [Linenoise](http://github.com/antirez/linenoise) library. A minimal, zero-config readline replacement. 4 | 5 | ### Supports 6 | * Mac OS and Linux 7 | * Line editing with emacs keybindings 8 | * History handling 9 | * Completion 10 | * Hints 11 | 12 | ### Pure Swift 13 | Implemented in pure Swift, with a Swifty API, this library is easy to embed in projects using Swift Package Manager, and requires no additional dependencies. 14 | 15 | ## Contents 16 | - [API](#api) 17 | * [Quick Start](#quick-start) 18 | * [Basics](#basics) 19 | * [History](#history) 20 | + [Adding to History](#adding-to-history) 21 | + [Limit the Number of Items in History](#limit-the-number-of-items-in-history) 22 | + [Saving the History to a File](#saving-the-history-to-a-file) 23 | + [Loading History From a File](#loading-history-from-a-file) 24 | + [History Editing Behavior](#history-editing-behavior) 25 | * [Completion](#completion) 26 | * [Hints](#hints) 27 | - [Acknowledgements](#acknowledgements) 28 | 29 | # API 30 | 31 | ## Quick Start 32 | Linenoise-Swift is easy to use, and can be used as a replacement for [`Swift.readLine`](https://developer.apple.com/documentation/swift/1641199-readline). Here is a simple example: 33 | 34 | ```swift 35 | let ln = LineNoise() 36 | 37 | do { 38 | let input = try ln.getLine(prompt: "> ") 39 | } catch { 40 | print(error) 41 | } 42 | 43 | ``` 44 | 45 | ## Basics 46 | Simply creating a new `LineNoise` object is all that is necessary in most cases, with STDIN used for input and STDOUT used for output by default. However, it is possible to supply different files for input and output if you wish: 47 | 48 | ```swift 49 | // 'in' and 'out' are standard POSIX file handles 50 | let ln = LineNoise(inputFile: in, outputFile: out) 51 | ``` 52 | 53 | ## History 54 | ### Adding to History 55 | Adding to the history is easy: 56 | 57 | ```swift 58 | let ln = LineNoise() 59 | 60 | do { 61 | let input = try ln.getLine(prompt: "> ") 62 | ln.addHistory(input) 63 | } catch { 64 | print(error) 65 | } 66 | ``` 67 | 68 | ### Limit the Number of Items in History 69 | You can optionally set the maximum amount of items to keep in history. Setting this to `0` (the default) will keep an unlimited amount of items in history. 70 | ```swift 71 | ln.setHistoryMaxLength(100) 72 | ``` 73 | 74 | ### Saving the History to a File 75 | ```swift 76 | ln.saveHistory(toFile: "/tmp/history.txt") 77 | ``` 78 | 79 | ### Loading History From a File 80 | This will add all of the items from the file to the current history 81 | ```swift 82 | ln.loadHistory(fromFile: "/tmp/history.txt") 83 | ``` 84 | 85 | ### History Editing Behavior 86 | By default, any edits by the user to a line in the history will be discarded if the user moves forward or back in the history without pressing Enter. If you prefer to have all edits preserved, then use the following: 87 | ```swift 88 | ln.preserveHistoryEdits = true 89 | ``` 90 | 91 | ## Completion 92 | ![Completion example](https://github.com/andybest/linenoise-swift/raw/master/images/completion.gif) 93 | 94 | Linenoise supports completion with `tab`. You can provide a callback to return an array of possible completions: 95 | 96 | ```swift 97 | let ln = LineNoise() 98 | 99 | ln.setCompletionCallback { currentBuffer in 100 | let completions = [ 101 | "Hello, world!", 102 | "Hello, Linenoise!", 103 | "Swift is Awesome!" 104 | ] 105 | 106 | return completions.filter { $0.hasPrefix(currentBuffer) } 107 | } 108 | ``` 109 | 110 | The completion callback gives you whatever has been typed before `tab` is pressed. Simply return an array of Strings for possible completions. These can be cycled through by pressing `tab` multiple times. 111 | 112 | ## Hints 113 | ![Hints example](https://github.com/andybest/linenoise-swift/raw/master/images/hints.gif) 114 | 115 | Linenoise supports providing hints as you type. These will appear to the right of the current input, and can be selected by pressing `Return`. 116 | 117 | The hints callback has the contents of the current line as input, and returns a tuple consisting of an optional hint string and an optional color for the hint text, e.g.: 118 | 119 | ```swift 120 | let ln = LineNoise() 121 | 122 | ln.setHintsCallback { currentBuffer in 123 | let hints = [ 124 | "Carpe Diem", 125 | "Lorem Ipsum", 126 | "Swift is Awesome!" 127 | ] 128 | 129 | let filtered = hints.filter { $0.hasPrefix(currentBuffer) } 130 | 131 | if let hint = filtered.first { 132 | // Make sure you return only the missing part of the hint 133 | let hintText = String(hint.dropFirst(currentBuffer.count)) 134 | 135 | // (R, G, B) 136 | let color = (127, 0, 127) 137 | 138 | return (hintText, color) 139 | } else { 140 | return (nil, nil) 141 | } 142 | } 143 | 144 | ``` 145 | 146 | # Acknowledgements 147 | Linenoise-Swift is heavily based on the [original linenoise library](http://github.com/antirez/linenoise) by [Salvatore Sanfilippo (antirez)](http://github.com/antirez) 148 | -------------------------------------------------------------------------------- /Sources/linenoise/AnsiCodes.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2017, Andy Best 3 | Copyright (c) 2010-2014, Salvatore Sanfilippo 4 | Copyright (c) 2010-2013, Pieter Noordhuis 5 | 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, 12 | this list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, 15 | this list of conditions and the following disclaimer in the documentation 16 | and/or other materials provided with the distribution. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | */ 29 | 30 | import Foundation 31 | 32 | public struct AnsiCodes { 33 | 34 | public static var eraseRight: String { 35 | return escapeCode("0K") 36 | } 37 | 38 | public static var homeCursor: String { 39 | return escapeCode("H") 40 | } 41 | 42 | public static var clearScreen: String { 43 | return escapeCode("2J") 44 | } 45 | 46 | public static var cursorLocation: String { 47 | return escapeCode("6n") 48 | } 49 | 50 | public static func escapeCode(_ input: String) -> String { 51 | return "\u{001B}[" + input 52 | } 53 | 54 | public static func cursorForward(_ columns: Int) -> String { 55 | return escapeCode("\(columns)C") 56 | } 57 | 58 | public static func termColor(color: Int, bold: Bool) -> String { 59 | return escapeCode("\(color);\(bold ? 1 : 0);49m") 60 | } 61 | 62 | public static func termColor256(color: Int) -> String { 63 | return escapeCode("38;5;\(color)m") 64 | } 65 | 66 | public static var origTermColor: String { 67 | return escapeCode("0m") 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /Sources/linenoise/ControlCharacters.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2017, Andy Best 3 | Copyright (c) 2010-2014, Salvatore Sanfilippo 4 | Copyright (c) 2010-2013, Pieter Noordhuis 5 | 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, 12 | this list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, 15 | this list of conditions and the following disclaimer in the documentation 16 | and/or other materials provided with the distribution. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | */ 29 | 30 | import Foundation 31 | 32 | internal enum ControlCharacters: UInt8 { 33 | case Null = 0 34 | case Ctrl_A = 1 35 | case Ctrl_B = 2 36 | case Ctrl_C = 3 37 | case Ctrl_D = 4 38 | case Ctrl_E = 5 39 | case Ctrl_F = 6 40 | case Bell = 7 41 | case Ctrl_H = 8 42 | case Tab = 9 43 | case Ctrl_K = 11 44 | case Ctrl_L = 12 45 | case Enter = 13 46 | case Ctrl_N = 14 47 | case Ctrl_P = 16 48 | case Ctrl_T = 20 49 | case Ctrl_U = 21 50 | case Ctrl_W = 23 51 | case Esc = 27 52 | case Backspace = 127 53 | 54 | var character: Character { 55 | return Character(UnicodeScalar(Int(self.rawValue))!) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/linenoise/EditState.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2017, Andy Best 3 | Copyright (c) 2010-2014, Salvatore Sanfilippo 4 | Copyright (c) 2010-2013, Pieter Noordhuis 5 | 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, 12 | this list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, 15 | this list of conditions and the following disclaimer in the documentation 16 | and/or other materials provided with the distribution. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | */ 29 | 30 | import Foundation 31 | 32 | internal class EditState { 33 | var buffer: String = "" 34 | var location: String.Index 35 | let prompt: String 36 | 37 | public var currentBuffer: String { 38 | return buffer 39 | } 40 | 41 | init(prompt: String) { 42 | self.prompt = prompt 43 | location = buffer.endIndex 44 | } 45 | 46 | var cursorPosition: Int { 47 | return buffer.distance(from: buffer.startIndex, to: location) 48 | } 49 | 50 | func insertCharacter(_ char: Character) { 51 | let origLoc = location 52 | let origEnd = buffer.endIndex 53 | buffer.insert(char, at: location) 54 | location = buffer.index(after: location) 55 | 56 | if origLoc == origEnd { 57 | location = buffer.endIndex 58 | } 59 | } 60 | 61 | func backspace() -> Bool { 62 | if location != buffer.startIndex { 63 | if location != buffer.startIndex { 64 | location = buffer.index(before: location) 65 | } 66 | 67 | buffer.remove(at: location) 68 | return true 69 | } 70 | return false 71 | } 72 | 73 | func moveLeft() -> Bool { 74 | if location == buffer.startIndex { 75 | return false 76 | } 77 | 78 | location = buffer.index(before: location) 79 | return true 80 | } 81 | 82 | func moveRight() -> Bool { 83 | if location == buffer.endIndex { 84 | return false 85 | } 86 | 87 | location = buffer.index(after: location) 88 | return true 89 | } 90 | 91 | func moveHome() -> Bool { 92 | if location == buffer.startIndex { 93 | return false 94 | } 95 | 96 | location = buffer.startIndex 97 | return true 98 | } 99 | 100 | func moveEnd() -> Bool { 101 | if location == buffer.endIndex { 102 | return false 103 | } 104 | 105 | location = buffer.endIndex 106 | return true 107 | } 108 | 109 | func deleteCharacter() -> Bool { 110 | if location >= currentBuffer.endIndex || currentBuffer.isEmpty { 111 | return false 112 | } 113 | 114 | buffer.remove(at: location) 115 | return true 116 | } 117 | 118 | func eraseCharacterRight() -> Bool { 119 | if buffer.count == 0 || location >= buffer.endIndex { 120 | return false 121 | } 122 | 123 | buffer.remove(at: location) 124 | 125 | if location > buffer.endIndex { 126 | location = buffer.endIndex 127 | } 128 | 129 | return true 130 | } 131 | 132 | func deletePreviousWord() -> Bool { 133 | let oldLocation = location 134 | 135 | // Go backwards to find the first non space character 136 | while location > buffer.startIndex && buffer[buffer.index(before: location)] == " " { 137 | location = buffer.index(before: location) 138 | } 139 | 140 | // Go backwards to find the next space character (start of the word) 141 | while location > buffer.startIndex && buffer[buffer.index(before: location)] != " " { 142 | location = buffer.index(before: location) 143 | } 144 | 145 | if buffer.distance(from: oldLocation, to: location) == 0 { 146 | return false 147 | } 148 | 149 | buffer.removeSubrange(location.. Bool { 155 | if location == buffer.endIndex || buffer.isEmpty { 156 | return false 157 | } 158 | 159 | buffer.removeLast(buffer.count - cursorPosition) 160 | return true 161 | } 162 | 163 | func swapCharacterWithPrevious() -> Bool { 164 | // Mimic ZSH behavior 165 | 166 | if buffer.count < 2 { 167 | return false 168 | } 169 | 170 | if location == buffer.endIndex { 171 | // Swap the two previous characters if at end index 172 | let temp = buffer.remove(at: buffer.index(location, offsetBy: -2)) 173 | buffer.insert(temp, at: buffer.endIndex) 174 | location = buffer.endIndex 175 | return true 176 | } else if location > buffer.startIndex { 177 | // If the characters are in the middle of the string, swap character under cursor with previous, 178 | // then move the cursor to the right 179 | let temp = buffer.remove(at: buffer.index(before: location)) 180 | buffer.insert(temp, at: location) 181 | 182 | if location < buffer.endIndex { 183 | location = buffer.index(after: location) 184 | } 185 | return true 186 | } else if location == buffer.startIndex { 187 | // If the character is at the start of the string, swap the first two characters, then put the cursor 188 | // after them 189 | let temp = buffer.remove(at: location) 190 | buffer.insert(temp, at: buffer.index(after: location)) 191 | if location < buffer.endIndex { 192 | location = buffer.index(buffer.startIndex, offsetBy: 2) 193 | } 194 | return true 195 | } 196 | 197 | return false 198 | } 199 | 200 | func withTemporaryState(_ body: () throws -> () ) throws { 201 | let originalBuffer = buffer 202 | let originalLocation = location 203 | 204 | try body() 205 | 206 | buffer = originalBuffer 207 | location = originalLocation 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /Sources/linenoise/Errors.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2017, Andy Best 3 | Copyright (c) 2010-2014, Salvatore Sanfilippo 4 | Copyright (c) 2010-2013, Pieter Noordhuis 5 | 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, 12 | this list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, 15 | this list of conditions and the following disclaimer in the documentation 16 | and/or other materials provided with the distribution. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | */ 29 | 30 | import Foundation 31 | 32 | public enum LinenoiseError: Error { 33 | case notATTY 34 | case generalError(String) 35 | case EOF 36 | case CTRL_C 37 | } 38 | -------------------------------------------------------------------------------- /Sources/linenoise/History.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2017, Andy Best 3 | Copyright (c) 2010-2014, Salvatore Sanfilippo 4 | Copyright (c) 2010-2013, Pieter Noordhuis 5 | 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, 12 | this list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, 15 | this list of conditions and the following disclaimer in the documentation 16 | and/or other materials provided with the distribution. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | */ 29 | 30 | import Foundation 31 | 32 | internal class History { 33 | 34 | public enum HistoryDirection: Int { 35 | case previous = -1 36 | case next = 1 37 | } 38 | 39 | var maxLength: UInt = 0 { 40 | didSet { 41 | if history.count > maxLength && maxLength > 0 { 42 | history.removeFirst(history.count - Int(maxLength)) 43 | } 44 | } 45 | } 46 | private var index: Int = 0 47 | 48 | var currentIndex: Int { 49 | return index 50 | } 51 | 52 | private var hasTempItem: Bool = false 53 | 54 | private var history: [String] = [String]() 55 | var historyItems: [String] { 56 | return history 57 | } 58 | 59 | public func add(_ item: String) { 60 | // Don't add a duplicate if the last item is equal to this one 61 | if let lastItem = history.last { 62 | if lastItem == item { 63 | // Reset the history pointer to the end index 64 | index = history.endIndex 65 | return 66 | } 67 | } 68 | 69 | // Remove an item if we have reached maximum length 70 | if maxLength > 0 && history.count >= maxLength { 71 | _ = history.removeFirst() 72 | } 73 | 74 | history.append(item) 75 | 76 | // Reset the history pointer to the end index 77 | index = history.endIndex 78 | } 79 | 80 | func replaceCurrent(_ item: String) { 81 | history[index] = item 82 | } 83 | 84 | // MARK: - History Navigation 85 | 86 | internal func navigateHistory(direction: HistoryDirection) -> String? { 87 | if history.count == 0 { 88 | return nil 89 | } 90 | 91 | switch direction { 92 | case .next: 93 | index += HistoryDirection.next.rawValue 94 | case .previous: 95 | index += HistoryDirection.previous.rawValue 96 | } 97 | 98 | // Stop at the beginning and end of history 99 | if index < 0 { 100 | index = 0 101 | return nil 102 | } else if index >= history.count { 103 | index = history.count 104 | return nil 105 | } 106 | 107 | return history[index] 108 | } 109 | 110 | // MARK: - Saving and loading 111 | 112 | internal func save(toFile path: String) throws { 113 | let output = history.joined(separator: "\n") 114 | try output.write(toFile: path, atomically: true, encoding: .utf8) 115 | } 116 | 117 | internal func load(fromFile path: String) throws { 118 | let input = try String(contentsOfFile: path, encoding: .utf8) 119 | 120 | input.split(separator: "\n").forEach { 121 | add(String($0)) 122 | } 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /Sources/linenoise/Terminal.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2017, Andy Best 3 | Copyright (c) 2010-2014, Salvatore Sanfilippo 4 | Copyright (c) 2010-2013, Pieter Noordhuis 5 | 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, 12 | this list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, 15 | this list of conditions and the following disclaimer in the documentation 16 | and/or other materials provided with the distribution. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | */ 29 | 30 | 31 | import Foundation 32 | 33 | internal struct Terminal { 34 | 35 | static func isTTY(_ fileHandle: Int32) -> Bool { 36 | let rv = isatty(fileHandle) 37 | return rv == 1 38 | } 39 | 40 | // MARK: Raw Mode 41 | static func withRawMode(_ fileHandle: Int32, body: () throws -> ()) throws { 42 | if !isTTY(fileHandle) { 43 | throw LinenoiseError.notATTY 44 | } 45 | 46 | var originalTermios: termios = termios() 47 | 48 | defer { 49 | // Disable raw mode 50 | _ = tcsetattr(fileHandle, TCSAFLUSH, &originalTermios) 51 | } 52 | 53 | if tcgetattr(fileHandle, &originalTermios) == -1 { 54 | throw LinenoiseError.generalError("Could not get term attributes") 55 | } 56 | 57 | var raw = originalTermios 58 | 59 | #if os(Linux) || os(FreeBSD) 60 | raw.c_iflag &= ~UInt32(BRKINT | ICRNL | INPCK | ISTRIP | IXON) 61 | raw.c_oflag &= ~UInt32(OPOST) 62 | raw.c_cflag |= UInt32(CS8) 63 | raw.c_lflag &= ~UInt32(ECHO | ICANON | IEXTEN | ISIG) 64 | #else 65 | raw.c_iflag &= ~UInt(BRKINT | ICRNL | INPCK | ISTRIP | IXON) 66 | raw.c_oflag &= ~UInt(OPOST) 67 | raw.c_cflag |= UInt(CS8) 68 | raw.c_lflag &= ~UInt(ECHO | ICANON | IEXTEN | ISIG) 69 | #endif 70 | 71 | // VMIN = 16 72 | raw.c_cc.16 = 1 73 | 74 | if tcsetattr(fileHandle, Int32(TCSAFLUSH), &raw) < 0 { 75 | throw LinenoiseError.generalError("Could not set raw mode") 76 | } 77 | 78 | // Run the body 79 | try body() 80 | } 81 | 82 | // MARK: - Colors 83 | 84 | enum ColorSupport { 85 | case standard 86 | case twoFiftySix 87 | } 88 | 89 | // Colour tables from https://jonasjacek.github.io/colors/ 90 | // Format: (r, g, b) 91 | 92 | static let colors: [(Int, Int, Int)] = [ 93 | // Standard 94 | (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192), 95 | // High intensity 96 | (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0), (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255), 97 | // 256 color extended 98 | (0, 0, 0), (0, 0, 95), (0, 0, 135), (0, 0, 175), (0, 0, 215), (0, 0, 255), (0, 95, 0), (0, 95, 95), 99 | (0, 95, 135), (0, 95, 175), (0, 95, 215), (0, 95, 255), (0, 135, 0), (0, 135, 95), (0, 135, 135), 100 | (0, 135, 175), (0, 135, 215), (0, 135, 255), (0, 175, 0), (0, 175, 95), (0, 175, 135), (0, 175, 175), 101 | (0, 175, 215), (0, 175, 255), (0, 215, 0), (0, 215, 95), (0, 215, 135), (0, 215, 175), (0, 215, 215), 102 | (0, 215, 255), (0, 255, 0), (0, 255, 95), (0, 255, 135), (0, 255, 175), (0, 255, 215), (0, 255, 255), 103 | (95, 0, 0), (95, 0, 95), (95, 0, 135), (95, 0, 175), (95, 0, 215), (95, 0, 255), (95, 95, 0), (95, 95, 95), 104 | (95, 95, 135), (95, 95, 175), (95, 95, 215), (95, 95, 255), (95, 135, 0), (95, 135, 95), (95, 135, 135), 105 | (95, 135, 175), (95, 135, 215), (95, 135, 255), (95, 175, 0), (95, 175, 95), (95, 175, 135), (95, 175, 175), 106 | (95, 175, 215), (95, 175, 255), (95, 215, 0), (95, 215, 95), (95, 215, 135), (95, 215, 175), (95, 215, 215), 107 | (95, 215, 255), (95, 255, 0), (95, 255, 95), (95, 255, 135), (95, 255, 175), (95, 255, 215), (95, 255, 255), 108 | (135, 0, 0), (135, 0, 95), (135, 0, 135), (135, 0, 175), (135, 0, 215), (135, 0, 255), (135, 95, 0), (135, 95, 95), 109 | (135, 95, 135), (135, 95, 175), (135, 95, 215), (135, 95, 255), (135, 135, 0), (135, 135, 95), (135, 135, 135), 110 | (135, 135, 175), (135, 135, 215), (135, 135, 255), (135, 175, 0), (135, 175, 95), (135, 175, 135), 111 | (135, 175, 175), (135, 175, 215), (135, 175, 255), (135, 215, 0), (135, 215, 95), (135, 215, 135), 112 | (135, 215, 175), (135, 215, 215), (135, 215, 255), (135, 255, 0), (135, 255, 95), (135, 255, 135), 113 | (135, 255, 175), (135, 255, 215), (135, 255, 255), (175, 0, 0), (175, 0, 95), (175, 0, 135), (175, 0, 175), 114 | (175, 0, 215), (175, 0, 255), (175, 95, 0), (175, 95, 95), (175, 95, 135), (175, 95, 175), (175, 95, 215), 115 | (175, 95, 255), (175, 135, 0), (175, 135, 95), (175, 135, 135), (175, 135, 175), (175, 135, 215), 116 | (175, 135, 255), (175, 175, 0), (175, 175, 95), (175, 175, 135), (175, 175, 175), (175, 175, 215), 117 | (175, 175, 255), (175, 215, 0), (175, 215, 95), (175, 215, 135), (175, 215, 175), (175, 215, 215), 118 | (175, 215, 255), (175, 255, 0), (175, 255, 95), (175, 255, 135), (175, 255, 175), (175, 255, 215), 119 | (175, 255, 255), (215, 0, 0), (215, 0, 95), (215, 0, 135), (215, 0, 175), (215, 0, 215), (215, 0, 255), 120 | (215, 95, 0), (215, 95, 95), (215, 95, 135), (215, 95, 175), (215, 95, 215), (215, 95, 255), (215, 135, 0), 121 | (215, 135, 95), (215, 135, 135), (215, 135, 175), (215, 135, 215), (215, 135, 255), (215, 175, 0), 122 | (215, 175, 95), (215, 175, 135), (215, 175, 175), (215, 175, 215), (215, 175, 255), (215, 215, 0), 123 | (215, 215, 95), (215, 215, 135), (215, 215, 175), (215, 215, 215), (215, 215, 255), (215, 255, 0), 124 | (215, 255, 95), (215, 255, 135), (215, 255, 175), (215, 255, 215), (215, 255, 255), (255, 0, 0), 125 | (255, 0, 95), (255, 0, 135), (255, 0, 175), (255, 0, 215), (255, 0, 255), (255, 95, 0), (255, 95, 95), 126 | (255, 95, 135), (255, 95, 175), (255, 95, 215), (255, 95, 255), (255, 135, 0), (255, 135, 95), 127 | (255, 135, 135), (255, 135, 175), (255, 135, 215), (255, 135, 255), (255, 175, 0), (255, 175, 95), 128 | (255, 175, 135), (255, 175, 175), (255, 175, 215), (255, 175, 255), (255, 215, 0), (255, 215, 95), 129 | (255, 215, 135), (255, 215, 175), (255, 215, 215), (255, 215, 255), (255, 255, 0), (255, 255, 95), 130 | (255, 255, 135), (255, 255, 175), (255, 255, 215), (255, 255, 255), (8, 8, 8), (18, 18, 18), 131 | (28, 28, 28), (38, 38, 38), (48, 48, 48), (58, 58, 58), (68, 68, 68), (78, 78, 78), (88, 88, 88), 132 | (98, 98, 98), (108, 108, 108), (118, 118, 118), (128, 128, 128), (138, 138, 138), (148, 148, 148), 133 | (158, 158, 158), (168, 168, 168), (178, 178, 178), (188, 188, 188), (198, 198, 198), (208, 208, 208), 134 | (218, 218, 218), (228, 228, 228), (238, 238, 238) 135 | ] 136 | 137 | static func termColorSupport(termVar: String) -> ColorSupport { 138 | // A rather dumb way of detecting colour support 139 | 140 | if termVar.contains("256") { 141 | return .twoFiftySix 142 | } 143 | 144 | return .standard 145 | } 146 | 147 | static func closestColor(to targetColor: (Int, Int, Int), withColorSupport colorSupport: ColorSupport) -> Int { 148 | let colorTable: [(Int, Int, Int)] 149 | 150 | switch colorSupport { 151 | case .standard: 152 | colorTable = Array(colors[0..<8]) 153 | case .twoFiftySix: 154 | colorTable = colors 155 | } 156 | 157 | let distances = colorTable.map { 158 | sqrt(pow(Double($0.0 - targetColor.0), 2) + 159 | pow(Double($0.1 - targetColor.1), 2) + 160 | pow(Double($0.2 - targetColor.2), 2)) 161 | } 162 | 163 | var closest = Double.greatestFiniteMagnitude 164 | var closestIdx = 0 165 | 166 | for i in 0.. 3 | Copyright (c) 2010-2014, Salvatore Sanfilippo 4 | Copyright (c) 2010-2013, Pieter Noordhuis 5 | 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, 12 | this list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, 15 | this list of conditions and the following disclaimer in the documentation 16 | and/or other materials provided with the distribution. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | */ 29 | 30 | #if os(Linux) || os(FreeBSD) 31 | import Glibc 32 | #else 33 | import Darwin 34 | #endif 35 | import Foundation 36 | 37 | 38 | public class LineNoise { 39 | public enum Mode { 40 | case unsupportedTTY 41 | case supportedTTY 42 | case notATTY 43 | } 44 | 45 | public let mode: Mode 46 | 47 | /** 48 | If false (the default) any edits by the user to a line in the history 49 | will be discarded if the user moves forward or back in the history 50 | without pressing Enter. If true, all history edits will be preserved. 51 | */ 52 | public var preserveHistoryEdits = false 53 | 54 | var history: History = History() 55 | 56 | var completionCallback: ((String) -> ([String]))? 57 | var hintsCallback: ((String) -> (String?, (Int, Int, Int)?))? 58 | 59 | let currentTerm: String 60 | 61 | var tempBuf: String? 62 | 63 | let inputFile: Int32 64 | let outputFile: Int32 65 | 66 | // MARK: - Public Interface 67 | 68 | /** 69 | #init 70 | - parameter inputFile: a POSIX file handle for the input 71 | - parameter outputFile: a POSIX file handle for the output 72 | */ 73 | public init(inputFile: Int32 = STDIN_FILENO, outputFile: Int32 = STDOUT_FILENO) { 74 | self.inputFile = inputFile 75 | self.outputFile = outputFile 76 | 77 | currentTerm = ProcessInfo.processInfo.environment["TERM"] ?? "" 78 | if !Terminal.isTTY(inputFile) { 79 | mode = .notATTY 80 | } 81 | else if LineNoise.isUnsupportedTerm(currentTerm) { 82 | mode = .unsupportedTTY 83 | } 84 | else { 85 | mode = .supportedTTY 86 | } 87 | } 88 | 89 | /** 90 | #addHistory 91 | Adds a string to the history buffer 92 | - parameter item: Item to add 93 | */ 94 | public func addHistory(_ item: String) { 95 | history.add(item) 96 | } 97 | 98 | /** 99 | #setCompletionCallback 100 | Adds a callback for tab completion 101 | - parameter callback: A callback taking the current text and returning an array of Strings containing possible completions 102 | */ 103 | public func setCompletionCallback(_ callback: @escaping (String) -> ([String]) ) { 104 | completionCallback = callback 105 | } 106 | 107 | /** 108 | #setHintsCallback 109 | Adds a callback for hints as you type 110 | - parameter callback: A callback taking the current text and optionally returning the hint and a tuple of RGB colours for the hint text 111 | */ 112 | public func setHintsCallback(_ callback: @escaping (String) -> (String?, (Int, Int, Int)?)) { 113 | hintsCallback = callback 114 | } 115 | 116 | /** 117 | #loadHistory 118 | Loads history from a file and appends it to the current history buffer 119 | - parameter path: The path of the history file 120 | - Throws: Can throw an error if the file cannot be found or loaded 121 | */ 122 | public func loadHistory(fromFile path: String) throws { 123 | try history.load(fromFile: path) 124 | } 125 | 126 | /** 127 | #saveHistory 128 | Saves history to a file 129 | - parameter path: The path of the history file to save 130 | - Throws: Can throw an error if the file cannot be written to 131 | */ 132 | public func saveHistory(toFile path: String) throws { 133 | try history.save(toFile: path) 134 | } 135 | 136 | /* 137 | #setHistoryMaxLength 138 | Sets the maximum amount of items to keep in history. If this limit is reached, the oldest item is discarded when a new item is added. 139 | - parameter historyMaxLength: The maximum length of history. Setting this to 0 (the default) will keep 'unlimited' items in history 140 | */ 141 | public func setHistoryMaxLength(_ historyMaxLength: UInt) { 142 | history.maxLength = historyMaxLength 143 | } 144 | 145 | /** 146 | #clearScreen 147 | Clears the screen. 148 | - Throws: Can throw an error if the terminal cannot be written to. 149 | */ 150 | public func clearScreen() throws { 151 | try output(text: AnsiCodes.homeCursor) 152 | try output(text: AnsiCodes.clearScreen) 153 | } 154 | 155 | /** 156 | #getLine 157 | The main function of Linenoise. Gets a line of input from the user. 158 | - parameter prompt: The prompt to be shown to the user at the beginning of the line.] 159 | - Returns: The input from the user 160 | - Throws: Can throw an error if the terminal cannot be written to. 161 | */ 162 | public func getLine(prompt: String) throws -> String { 163 | // If there was any temporary history, remove it 164 | tempBuf = nil 165 | 166 | switch mode { 167 | case .notATTY: 168 | return getLineNoTTY(prompt: prompt) 169 | 170 | case .unsupportedTTY: 171 | return try getLineUnsupportedTTY(prompt: prompt) 172 | 173 | case .supportedTTY: 174 | return try getLineRaw(prompt: prompt) 175 | } 176 | } 177 | 178 | // MARK: - Terminal handling 179 | 180 | private static func isUnsupportedTerm(_ term: String) -> Bool { 181 | #if os(macOS) 182 | if let xpcServiceName = ProcessInfo.processInfo.environment["XPC_SERVICE_NAME"], xpcServiceName.localizedCaseInsensitiveContains("com.apple.dt.xcode") { 183 | return true 184 | } 185 | #endif 186 | return ["", "dumb", "cons25", "emacs"].contains(term) 187 | } 188 | 189 | // MARK: - Text input 190 | internal func readCharacter(inputFile: Int32) -> UInt8? { 191 | var input: UInt8 = 0 192 | let count = read(inputFile, &input, 1) 193 | 194 | if count == 0 { 195 | return nil 196 | } 197 | 198 | return input 199 | } 200 | 201 | // MARK: - Text output 202 | 203 | private func output(character: ControlCharacters) throws { 204 | try output(character: character.character) 205 | } 206 | 207 | internal func output(character: Character) throws { 208 | if write(outputFile, String(character), 1) == -1 { 209 | throw LinenoiseError.generalError("Unable to write to output") 210 | } 211 | } 212 | 213 | internal func output(text: String) throws { 214 | if write(outputFile, text, text.count) == -1 { 215 | throw LinenoiseError.generalError("Unable to write to output") 216 | } 217 | } 218 | 219 | // MARK: - Cursor movement 220 | internal func updateCursorPosition(editState: EditState) throws { 221 | try output(text: "\r" + AnsiCodes.cursorForward(editState.cursorPosition + editState.prompt.count)) 222 | } 223 | 224 | internal func moveLeft(editState: EditState) throws { 225 | // Left 226 | if editState.moveLeft() { 227 | try updateCursorPosition(editState: editState) 228 | } else { 229 | try output(character: ControlCharacters.Bell.character) 230 | } 231 | } 232 | 233 | internal func moveRight(editState: EditState) throws { 234 | // Left 235 | if editState.moveRight() { 236 | try updateCursorPosition(editState: editState) 237 | } else { 238 | try output(character: ControlCharacters.Bell.character) 239 | } 240 | } 241 | 242 | internal func moveHome(editState: EditState) throws { 243 | if editState.moveHome() { 244 | try updateCursorPosition(editState: editState) 245 | } else { 246 | try output(character: ControlCharacters.Bell.character) 247 | } 248 | } 249 | 250 | internal func moveEnd(editState: EditState) throws { 251 | if editState.moveEnd() { 252 | try updateCursorPosition(editState: editState) 253 | } else { 254 | try output(character: ControlCharacters.Bell.character) 255 | } 256 | } 257 | 258 | internal func getCursorXPosition(inputFile: Int32, outputFile: Int32) -> Int? { 259 | do { 260 | try output(text: AnsiCodes.cursorLocation) 261 | } catch { 262 | return nil 263 | } 264 | 265 | var buf = [UInt8]() 266 | 267 | var i = 0 268 | while true { 269 | if let c = readCharacter(inputFile: inputFile) { 270 | buf[i] = c 271 | } else { 272 | return nil 273 | } 274 | 275 | if buf[i] == 82 { // "R" 276 | break 277 | } 278 | 279 | i += 1 280 | } 281 | 282 | // Check the first characters are the escape code 283 | if buf[0] != 0x1B || buf[1] != 0x5B { 284 | return nil 285 | } 286 | 287 | let positionText = String(bytes: buf[2.. Int { 300 | var winSize = winsize() 301 | 302 | if ioctl(1, UInt(TIOCGWINSZ), &winSize) == -1 || winSize.ws_col == 0 { 303 | // Couldn't get number of columns with ioctl 304 | guard let start = getCursorXPosition(inputFile: inputFile, outputFile: outputFile) else { 305 | return 80 306 | } 307 | 308 | do { 309 | try output(text: AnsiCodes.cursorForward(999)) 310 | } catch { 311 | return 80 312 | } 313 | 314 | guard let cols = getCursorXPosition(inputFile: inputFile, outputFile: outputFile) else { 315 | return 80 316 | } 317 | 318 | // Restore original cursor position 319 | do { 320 | try output(text: "\r" + AnsiCodes.cursorForward(start)) 321 | } catch { 322 | // Can't recover from this 323 | } 324 | 325 | return cols 326 | } else { 327 | return Int(winSize.ws_col) 328 | } 329 | } 330 | 331 | // MARK: - Buffer manipulation 332 | internal func refreshLine(editState: EditState) throws { 333 | var commandBuf = "\r" // Return to beginning of the line 334 | commandBuf += editState.prompt 335 | commandBuf += editState.buffer 336 | commandBuf += try refreshHints(editState: editState) 337 | commandBuf += AnsiCodes.eraseRight 338 | 339 | // Put the cursor in the original position 340 | commandBuf += "\r" 341 | commandBuf += AnsiCodes.cursorForward(editState.cursorPosition + editState.prompt.count) 342 | 343 | try output(text: commandBuf) 344 | } 345 | 346 | internal func insertCharacter(_ char: Character, editState: EditState) throws { 347 | editState.insertCharacter(char) 348 | 349 | if editState.location == editState.buffer.endIndex { 350 | try output(character: char) 351 | } else { 352 | try refreshLine(editState: editState) 353 | } 354 | } 355 | 356 | internal func deleteCharacter(editState: EditState) throws { 357 | if !editState.deleteCharacter() { 358 | try output(character: ControlCharacters.Bell.character) 359 | } else { 360 | try refreshLine(editState: editState) 361 | } 362 | } 363 | 364 | // MARK: - Completion 365 | 366 | internal func completeLine(editState: EditState) throws -> UInt8? { 367 | if completionCallback == nil { 368 | return nil 369 | } 370 | 371 | let completions = completionCallback!(editState.currentBuffer) 372 | 373 | if completions.count == 0 { 374 | try output(character: ControlCharacters.Bell.character) 375 | return nil 376 | } 377 | 378 | var completionIndex = 0 379 | 380 | // Loop to handle inputs 381 | while true { 382 | if completionIndex < completions.count { 383 | try editState.withTemporaryState { 384 | editState.buffer = completions[completionIndex] 385 | _ = editState.moveEnd() 386 | 387 | try refreshLine(editState: editState) 388 | } 389 | 390 | } else { 391 | try refreshLine(editState: editState) 392 | } 393 | 394 | guard let char = readCharacter(inputFile: inputFile) else { 395 | return nil 396 | } 397 | 398 | switch char { 399 | case ControlCharacters.Tab.rawValue: 400 | // Move to next completion 401 | completionIndex = (completionIndex + 1) % (completions.count + 1) 402 | if completionIndex == completions.count { 403 | try output(character: ControlCharacters.Bell.character) 404 | } 405 | 406 | case ControlCharacters.Esc.rawValue: 407 | // Show the original buffer 408 | if completionIndex < completions.count { 409 | try refreshLine(editState: editState) 410 | } 411 | return char 412 | 413 | default: 414 | // Update the buffer and return 415 | if completionIndex < completions.count { 416 | editState.buffer = completions[completionIndex] 417 | _ = editState.moveEnd() 418 | } 419 | 420 | return char 421 | } 422 | } 423 | } 424 | 425 | // MARK: - History 426 | 427 | internal func moveHistory(editState: EditState, direction: History.HistoryDirection) throws { 428 | // If we're at the end of history (editing the current line), 429 | // push it into a temporary buffer so it can be retreived later. 430 | if history.currentIndex == history.historyItems.count { 431 | tempBuf = editState.currentBuffer 432 | } 433 | else if preserveHistoryEdits { 434 | history.replaceCurrent(editState.currentBuffer) 435 | } 436 | 437 | if let historyItem = history.navigateHistory(direction: direction) { 438 | editState.buffer = historyItem 439 | _ = editState.moveEnd() 440 | try refreshLine(editState: editState) 441 | } else { 442 | if case .next = direction { 443 | editState.buffer = tempBuf ?? "" 444 | _ = editState.moveEnd() 445 | try refreshLine(editState: editState) 446 | } else { 447 | try output(character: ControlCharacters.Bell.character) 448 | } 449 | } 450 | } 451 | 452 | // MARK: - Hints 453 | 454 | internal func refreshHints(editState: EditState) throws -> String { 455 | if hintsCallback != nil { 456 | var cmdBuf = "" 457 | 458 | let (hintOpt, color) = hintsCallback!(editState.buffer) 459 | 460 | guard let hint = hintOpt else { 461 | return "" 462 | } 463 | 464 | let currentLineLength = editState.prompt.count + editState.currentBuffer.count 465 | 466 | let numCols = getNumCols() 467 | 468 | // Don't display the hint if it won't fit. 469 | if hint.count + currentLineLength > numCols { 470 | return "" 471 | } 472 | 473 | let colorSupport = Terminal.termColorSupport(termVar: currentTerm) 474 | 475 | var outputColor = 0 476 | if color == nil { 477 | outputColor = 37 478 | } else { 479 | outputColor = Terminal.closestColor(to: color!, 480 | withColorSupport: colorSupport) 481 | } 482 | 483 | switch colorSupport { 484 | case .standard: 485 | cmdBuf += AnsiCodes.termColor(color: (outputColor & 0xF) + 30, bold: outputColor > 7) 486 | case .twoFiftySix: 487 | cmdBuf += AnsiCodes.termColor256(color: outputColor) 488 | } 489 | cmdBuf += hint 490 | cmdBuf += AnsiCodes.origTermColor 491 | 492 | return cmdBuf 493 | } 494 | 495 | return "" 496 | } 497 | 498 | // MARK: - Line editing 499 | 500 | internal func getLineNoTTY(prompt: String) -> String { 501 | return "" 502 | } 503 | 504 | internal func getLineRaw(prompt: String) throws -> String { 505 | var line: String = "" 506 | 507 | try Terminal.withRawMode(inputFile) { 508 | line = try editLine(prompt: prompt) 509 | } 510 | 511 | return line 512 | } 513 | 514 | internal func getLineUnsupportedTTY(prompt: String) throws -> String { 515 | // Since the terminal is unsupported, fall back to Swift's readLine. 516 | print(prompt, terminator: "") 517 | if let line = readLine() { 518 | return line 519 | } 520 | else { 521 | throw LinenoiseError.EOF 522 | } 523 | } 524 | 525 | internal func handleEscapeCode(editState: EditState) throws { 526 | var seq = [0, 0, 0] 527 | _ = read(inputFile, &seq[0], 1) 528 | _ = read(inputFile, &seq[1], 1) 529 | 530 | var seqStr = seq.map { Character(UnicodeScalar($0)!) } 531 | 532 | if seqStr[0] == "[" { 533 | if seqStr[1] >= "0" && seqStr[1] <= "9" { 534 | // Handle multi-byte sequence ^[[0... 535 | _ = read(inputFile, &seq[2], 1) 536 | seqStr = seq.map { Character(UnicodeScalar($0)!) } 537 | 538 | if seqStr[2] == "~" { 539 | switch seqStr[1] { 540 | case "1", "7": 541 | try moveHome(editState: editState) 542 | case "3": 543 | // Delete 544 | try deleteCharacter(editState: editState) 545 | case "4": 546 | try moveEnd(editState: editState) 547 | default: 548 | break 549 | } 550 | } 551 | } else { 552 | // ^[... 553 | switch seqStr[1] { 554 | case "A": 555 | try moveHistory(editState: editState, direction: .previous) 556 | case "B": 557 | try moveHistory(editState: editState, direction: .next) 558 | case "C": 559 | try moveRight(editState: editState) 560 | case "D": 561 | try moveLeft(editState: editState) 562 | case "H": 563 | try moveHome(editState: editState) 564 | case "F": 565 | try moveEnd(editState: editState) 566 | default: 567 | break 568 | } 569 | } 570 | } else if seqStr[0] == "O" { 571 | // ^[O... 572 | switch seqStr[1] { 573 | case "H": 574 | try moveHome(editState: editState) 575 | case "F": 576 | try moveEnd(editState: editState) 577 | default: 578 | break 579 | } 580 | } 581 | } 582 | 583 | internal func handleCharacter(_ char: UInt8, editState: EditState) throws -> String? { 584 | switch char { 585 | 586 | case ControlCharacters.Enter.rawValue: 587 | return editState.currentBuffer 588 | 589 | case ControlCharacters.Ctrl_A.rawValue: 590 | try moveHome(editState: editState) 591 | 592 | case ControlCharacters.Ctrl_E.rawValue: 593 | try moveEnd(editState: editState) 594 | 595 | case ControlCharacters.Ctrl_B.rawValue: 596 | try moveLeft(editState: editState) 597 | 598 | case ControlCharacters.Ctrl_C.rawValue: 599 | // Throw an error so that CTRL+C can be handled by the caller 600 | throw LinenoiseError.CTRL_C 601 | 602 | case ControlCharacters.Ctrl_D.rawValue: 603 | // If there is a character at the right of the cursor, remove it 604 | // If the cursor is at the end of the line, act as EOF 605 | if !editState.eraseCharacterRight() { 606 | if editState.currentBuffer.count == 0{ 607 | throw LinenoiseError.EOF 608 | } else { 609 | try output(character: .Bell) 610 | } 611 | } else { 612 | try refreshLine(editState: editState) 613 | } 614 | 615 | case ControlCharacters.Ctrl_P.rawValue: 616 | // Previous history item 617 | try moveHistory(editState: editState, direction: .previous) 618 | 619 | case ControlCharacters.Ctrl_N.rawValue: 620 | // Next history item 621 | try moveHistory(editState: editState, direction: .next) 622 | 623 | case ControlCharacters.Ctrl_L.rawValue: 624 | // Clear screen 625 | try clearScreen() 626 | try refreshLine(editState: editState) 627 | 628 | case ControlCharacters.Ctrl_T.rawValue: 629 | if !editState.swapCharacterWithPrevious() { 630 | try output(character: .Bell) 631 | } else { 632 | try refreshLine(editState: editState) 633 | } 634 | 635 | case ControlCharacters.Ctrl_U.rawValue: 636 | // Delete whole line 637 | editState.buffer = "" 638 | _ = editState.moveEnd() 639 | try refreshLine(editState: editState) 640 | 641 | case ControlCharacters.Ctrl_K.rawValue: 642 | // Delete to the end of the line 643 | if !editState.deleteToEndOfLine() { 644 | try output(character: .Bell) 645 | } 646 | try refreshLine(editState: editState) 647 | 648 | case ControlCharacters.Ctrl_W.rawValue: 649 | // Delete previous word 650 | if !editState.deletePreviousWord() { 651 | try output(character: .Bell) 652 | } else { 653 | try refreshLine(editState: editState) 654 | } 655 | 656 | case ControlCharacters.Backspace.rawValue: 657 | // Delete character 658 | if editState.backspace() { 659 | try refreshLine(editState: editState) 660 | } else { 661 | try output(character: .Bell) 662 | } 663 | 664 | case ControlCharacters.Esc.rawValue: 665 | try handleEscapeCode(editState: editState) 666 | 667 | default: 668 | // Insert character 669 | try insertCharacter(Character(UnicodeScalar(char)), editState: editState) 670 | try refreshLine(editState: editState) 671 | } 672 | 673 | return nil 674 | } 675 | 676 | internal func editLine(prompt: String) throws -> String { 677 | try output(text: prompt) 678 | 679 | let editState: EditState = EditState(prompt: prompt) 680 | 681 | while true { 682 | guard var char = readCharacter(inputFile: inputFile) else { 683 | return "" 684 | } 685 | 686 | if char == ControlCharacters.Tab.rawValue && completionCallback != nil { 687 | if let completionChar = try completeLine(editState: editState) { 688 | char = completionChar 689 | } 690 | } 691 | 692 | if let rv = try handleCharacter(char, editState: editState) { 693 | return rv 694 | } 695 | } 696 | } 697 | } 698 | -------------------------------------------------------------------------------- /Sources/linenoiseDemo/main.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2017, Andy Best 3 | Copyright (c) 2010-2014, Salvatore Sanfilippo 4 | Copyright (c) 2010-2013, Pieter Noordhuis 5 | 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, 12 | this list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, 15 | this list of conditions and the following disclaimer in the documentation 16 | and/or other materials provided with the distribution. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | */ 29 | 30 | import LineNoise 31 | import Foundation 32 | 33 | let ln = LineNoise() 34 | 35 | ln.setCompletionCallback { currentBuffer in 36 | let completions = [ 37 | "Hello, world!", 38 | "Hello, Linenoise!", 39 | "Swift is Awesome!" 40 | ] 41 | 42 | return completions.filter { $0.hasPrefix(currentBuffer) } 43 | } 44 | 45 | ln.setHintsCallback { currentBuffer in 46 | let hints = [ 47 | "Carpe Diem", 48 | "Lorem Ipsum", 49 | "Swift is Awesome!" 50 | ] 51 | 52 | let filtered = hints.filter { $0.hasPrefix(currentBuffer) } 53 | 54 | if let hint = filtered.first { 55 | // Make sure you return only the missing part of the hint 56 | let hintText = String(hint.dropFirst(currentBuffer.count)) 57 | 58 | // (R, G, B) 59 | let color = (127, 0, 127) 60 | 61 | return (hintText, color) 62 | } else { 63 | return (nil, nil) 64 | } 65 | } 66 | 67 | do { 68 | try ln.clearScreen() 69 | } catch { 70 | print(error) 71 | } 72 | 73 | print("Type 'exit' to quit") 74 | 75 | var done = false 76 | while !done { 77 | do { 78 | let output = try ln.getLine(prompt: "? ") 79 | print("\nOutput: \(output)") 80 | ln.addHistory(output) 81 | 82 | // Typing 'exit' will quit 83 | if output == "exit" { 84 | break 85 | } 86 | } catch LinenoiseError.CTRL_C { 87 | print("\nCaptured CTRL+C. Quitting.") 88 | done = true 89 | } catch { 90 | print(error) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import linenoiseTests 3 | 4 | XCTMain([ 5 | testCase(AnsiCodesTests.allTests), 6 | testCase(EditStateTests.allTests), 7 | testCase(HistoryTests.allTests), 8 | ]) 9 | -------------------------------------------------------------------------------- /Tests/linenoiseTests/AnsiCodesTests.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2017, Andy Best 3 | Copyright (c) 2010-2014, Salvatore Sanfilippo 4 | Copyright (c) 2010-2013, Pieter Noordhuis 5 | 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, 12 | this list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, 15 | this list of conditions and the following disclaimer in the documentation 16 | and/or other materials provided with the distribution. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | */ 29 | 30 | import XCTest 31 | import Nimble 32 | @testable import LineNoise 33 | 34 | class AnsiCodesTests: XCTestCase { 35 | 36 | func testGenerateEscapeCode() { 37 | expect(AnsiCodes.escapeCode("foo")).to(equal("\u{001B}[foo")) 38 | } 39 | 40 | func testEraseRight() { 41 | expect(AnsiCodes.eraseRight).to(equal("\u{001B}[0K")) 42 | } 43 | 44 | func testCursorForward() { 45 | expect(AnsiCodes.cursorForward(10)).to(equal("\u{001B}[10C")) 46 | } 47 | 48 | func testClearScreen() { 49 | expect(AnsiCodes.clearScreen).to(equal("\u{001B}[2J")) 50 | } 51 | 52 | func testHomeCursor() { 53 | expect(AnsiCodes.homeCursor).to(equal("\u{001B}[H")) 54 | } 55 | } 56 | 57 | #if os(Linux) || os(FreeBSD) 58 | extension AnsiCodesTests { 59 | static var allTests: [(String, (AnsiCodesTests) -> () throws -> Void)] { 60 | return [ 61 | ("testGenerateEscapeCode", testGenerateEscapeCode), 62 | ("testEraseRight", testEraseRight), 63 | ("testCursorForward", testCursorForward), 64 | ("testClearScreen", testClearScreen), 65 | ("testHomeCursor", testHomeCursor) 66 | ] 67 | } 68 | } 69 | #endif 70 | -------------------------------------------------------------------------------- /Tests/linenoiseTests/EditStateTests.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2017, Andy Best 3 | Copyright (c) 2010-2014, Salvatore Sanfilippo 4 | Copyright (c) 2010-2013, Pieter Noordhuis 5 | 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, 12 | this list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, 15 | this list of conditions and the following disclaimer in the documentation 16 | and/or other materials provided with the distribution. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | */ 29 | 30 | import XCTest 31 | import Nimble 32 | @testable import LineNoise 33 | 34 | class EditStateTests: XCTestCase { 35 | 36 | func testInitEmptyBuffer() { 37 | let s = EditState(prompt: "$ ") 38 | expect(s.currentBuffer).to(equal("")) 39 | expect(s.location).to(equal(s.currentBuffer.startIndex)) 40 | expect(s.prompt).to(equal("$ ")) 41 | } 42 | 43 | func testInsertCharacter() { 44 | let s = EditState(prompt: "") 45 | s.insertCharacter("A"["A".startIndex]) 46 | 47 | expect(s.buffer).to(equal("A")) 48 | expect(s.location).to(equal(s.currentBuffer.endIndex)) 49 | expect(s.cursorPosition).to(equal(1)) 50 | } 51 | 52 | func testBackspace() { 53 | let s = EditState(prompt: "") 54 | s.insertCharacter("A"["A".startIndex]) 55 | 56 | expect(s.backspace()).to(beTrue()) 57 | expect(s.currentBuffer).to(equal("")) 58 | expect(s.location).to(equal(s.currentBuffer.startIndex)) 59 | 60 | // No more characters left, so backspace should return false 61 | expect(s.backspace()).to(beFalse()) 62 | } 63 | 64 | func testMoveLeft() { 65 | let s = EditState(prompt: "") 66 | s.buffer = "Hello" 67 | s.location = s.currentBuffer.endIndex 68 | 69 | expect(s.moveLeft()).to(beTrue()) 70 | expect(s.cursorPosition).to(equal(4)) 71 | 72 | s.location = s.currentBuffer.startIndex 73 | expect(s.moveLeft()).to(beFalse()) 74 | } 75 | 76 | func testMoveRight() { 77 | let s = EditState(prompt: "") 78 | s.buffer = "Hello" 79 | s.location = s.currentBuffer.startIndex 80 | 81 | expect(s.moveRight()).to(beTrue()) 82 | expect(s.cursorPosition).to(equal(1)) 83 | 84 | s.location = s.currentBuffer.endIndex 85 | expect(s.moveRight()).to(beFalse()) 86 | } 87 | 88 | func testMoveHome() { 89 | let s = EditState(prompt: "") 90 | s.buffer = "Hello" 91 | s.location = s.currentBuffer.endIndex 92 | 93 | expect(s.moveHome()).to(beTrue()) 94 | expect(s.cursorPosition).to(equal(0)) 95 | 96 | expect(s.moveHome()).to(beFalse()) 97 | } 98 | 99 | func testMoveEnd() { 100 | let s = EditState(prompt: "") 101 | s.buffer = "Hello" 102 | s.location = s.currentBuffer.startIndex 103 | 104 | expect(s.moveEnd()).to(beTrue()) 105 | expect(s.cursorPosition).to(equal(5)) 106 | 107 | expect(s.moveEnd()).to(beFalse()) 108 | } 109 | 110 | func testRemovePreviousWord() { 111 | let s = EditState(prompt: "") 112 | s.buffer = "Hello world" 113 | s.location = s.currentBuffer.endIndex 114 | 115 | expect(s.deletePreviousWord()).to(beTrue()) 116 | expect(s.buffer).to(equal("Hello ")) 117 | expect(s.location).to(equal("Hello ".endIndex)) 118 | 119 | s.buffer = "" 120 | s.location = s.currentBuffer.endIndex 121 | 122 | expect(s.deletePreviousWord()).to(beFalse()) 123 | 124 | // Test with cursor location in the middle of the text 125 | s.buffer = "This is a test" 126 | s.location = s.currentBuffer.index(s.currentBuffer.startIndex, offsetBy: 8) 127 | 128 | expect(s.deletePreviousWord()).to(beTrue()) 129 | expect(s.buffer).to(equal("This a test")) 130 | } 131 | 132 | func testDeleteToEndOfLine() { 133 | let s = EditState(prompt: "") 134 | s.buffer = "Hello world" 135 | s.location = s.currentBuffer.endIndex 136 | 137 | expect(s.deleteToEndOfLine()).to(beFalse()) 138 | 139 | s.location = s.currentBuffer.index(s.currentBuffer.startIndex, offsetBy: 5) 140 | 141 | expect(s.deleteToEndOfLine()).to(beTrue()) 142 | expect(s.currentBuffer).to(equal("Hello")) 143 | } 144 | 145 | func testDeleteCharacter() { 146 | let s = EditState(prompt: "") 147 | s.buffer = "Hello world" 148 | s.location = s.currentBuffer.endIndex 149 | 150 | expect(s.deleteCharacter()).to(beFalse()) 151 | 152 | s.location = s.currentBuffer.startIndex 153 | 154 | expect(s.deleteCharacter()).to(beTrue()) 155 | expect(s.currentBuffer).to(equal("ello world")) 156 | 157 | s.location = s.currentBuffer.index(s.currentBuffer.startIndex, offsetBy: 5) 158 | 159 | expect(s.deleteCharacter()).to(beTrue()) 160 | expect(s.currentBuffer).to(equal("ello orld")) 161 | } 162 | 163 | func testEraseCharacterRight() { 164 | let s = EditState(prompt: "") 165 | s.buffer = "Hello" 166 | s.location = s.currentBuffer.endIndex 167 | 168 | expect(s.eraseCharacterRight()).to(beFalse()) 169 | 170 | s.location = s.currentBuffer.startIndex 171 | expect (s.eraseCharacterRight()).to(beTrue()) 172 | expect(s.currentBuffer).to(equal("ello")) 173 | 174 | // Test empty buffer 175 | s.buffer = "" 176 | s.location = s.currentBuffer.startIndex 177 | expect(s.eraseCharacterRight()).to(beFalse()) 178 | } 179 | 180 | func testSwapCharacters() { 181 | let s = EditState(prompt: "") 182 | s.buffer = "Hello" 183 | s.location = s.currentBuffer.endIndex 184 | 185 | // Cursor at the end of the text 186 | expect(s.swapCharacterWithPrevious()).to(beTrue()) 187 | expect(s.currentBuffer).to(equal("Helol")) 188 | expect(s.location).to(equal(s.currentBuffer.endIndex)) 189 | 190 | // Cursor in the middle of the text 191 | s.location = s.currentBuffer.index(before: s.currentBuffer.endIndex) 192 | expect(s.swapCharacterWithPrevious()).to(beTrue()) 193 | expect(s.currentBuffer).to(equal("Hello")) 194 | expect(s.location).to(equal(s.currentBuffer.endIndex)) 195 | 196 | // Cursor at the start of the text 197 | s.location = s.currentBuffer.startIndex 198 | expect(s.swapCharacterWithPrevious()).to(beTrue()) 199 | expect(s.currentBuffer).to(equal("eHllo")) 200 | expect(s.location).to(equal(s.currentBuffer.index(s.currentBuffer.startIndex, offsetBy: 2))) 201 | } 202 | } 203 | 204 | #if os(Linux) || os(FreeBSD) 205 | extension EditStateTests { 206 | static var allTests: [(String, (EditStateTests) -> () throws -> Void)] { 207 | return [ 208 | ("testInitEmptyBuffer", testInitEmptyBuffer), 209 | ("testInsertCharacter", testInsertCharacter), 210 | ("testBackspace", testBackspace), 211 | ("testMoveLeft", testMoveLeft), 212 | ("testMoveRight", testMoveRight), 213 | ("testMoveHome", testMoveHome), 214 | ("testMoveEnd", testMoveEnd), 215 | ("testRemovePreviousWord", testRemovePreviousWord), 216 | ("testDeleteToEndOfLine", testDeleteToEndOfLine), 217 | ("testDeleteCharacter", testDeleteCharacter), 218 | ("testEraseCharacterRight", testEraseCharacterRight) 219 | ] 220 | } 221 | } 222 | #endif 223 | -------------------------------------------------------------------------------- /Tests/linenoiseTests/HistoryTests.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2017, Andy Best 3 | Copyright (c) 2010-2014, Salvatore Sanfilippo 4 | Copyright (c) 2010-2013, Pieter Noordhuis 5 | 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, 12 | this list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, 15 | this list of conditions and the following disclaimer in the documentation 16 | and/or other materials provided with the distribution. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | */ 29 | 30 | import XCTest 31 | import Nimble 32 | @testable import LineNoise 33 | 34 | class HistoryTests: XCTestCase { 35 | 36 | // MARK: - Adding Items 37 | func testHistoryAddItem() { 38 | let h = History() 39 | h.add("Test") 40 | 41 | expect(h.historyItems).to(equal(["Test"])) 42 | } 43 | 44 | func testHistoryDoesNotAddDuplicatedLines() { 45 | let h = History() 46 | 47 | h.add("Test") 48 | h.add("Test") 49 | 50 | expect(h.historyItems).to(haveCount(1)) 51 | 52 | // Test adding a new item in-between doesn't de-dupe the newest line 53 | h.add("Test 2") 54 | h.add("Test") 55 | 56 | expect(h.historyItems).to(haveCount(3)) 57 | } 58 | 59 | func testHistoryHonorsMaxLength() { 60 | let h = History() 61 | h.maxLength = 2 62 | 63 | h.add("Test 1") 64 | h.add("Test 2") 65 | h.add("Test 3") 66 | 67 | expect(h.historyItems).to(haveCount(2)) 68 | expect(h.historyItems).to(equal(["Test 2", "Test 3"])) 69 | } 70 | 71 | func testHistoryRemovesEntriesWhenMaxLengthIsSet() { 72 | let h = History() 73 | 74 | h.add("Test 1") 75 | h.add("Test 2") 76 | h.add("Test 3") 77 | 78 | expect(h.historyItems).to(haveCount(3)) 79 | 80 | h.maxLength = 2 81 | 82 | expect(h.historyItems).to(haveCount(2)) 83 | expect(h.historyItems).to(equal(["Test 2", "Test 3"])) 84 | } 85 | 86 | // MARK: Navigation 87 | 88 | func testHistoryNavigationReturnsNilWhenHistoryEmpty() { 89 | let h = History() 90 | 91 | expect(h.navigateHistory(direction: .next)).to(beNil()) 92 | expect(h.navigateHistory(direction: .previous)).to(beNil()) 93 | } 94 | 95 | func testHistoryNavigationReturnsSingleItemWhenHistoryHasOneItem() { 96 | let h = History() 97 | h.add("Test") 98 | 99 | expect(h.navigateHistory(direction: .next)).to(beNil()) 100 | 101 | guard let previousItem = h.navigateHistory(direction: .previous) else { 102 | XCTFail("Expected previous item to not be nil") 103 | return 104 | } 105 | 106 | expect(previousItem).to(equal("Test")) 107 | } 108 | 109 | func testHistoryStopsAtBeginning() { 110 | let h = History() 111 | h.add("1") 112 | h.add("2") 113 | h.add("3") 114 | 115 | expect(h.navigateHistory(direction: .previous)).to(equal("3")) 116 | expect(h.navigateHistory(direction: .previous)).to(equal("2")) 117 | expect(h.navigateHistory(direction: .previous)).to(equal("1")) 118 | expect(h.navigateHistory(direction: .previous)).to(beNil()) 119 | } 120 | 121 | func testHistoryNavigationStopsAtEnd() { 122 | let h = History() 123 | h.add("1") 124 | h.add("2") 125 | h.add("3") 126 | 127 | expect(h.navigateHistory(direction: .next)).to(beNil()) 128 | } 129 | 130 | func testHistoryNavigationAfterAddingDuplicateLines() { 131 | let h = History() 132 | h.add("1") 133 | h.add("2") 134 | 135 | expect(h.navigateHistory(direction: .previous)).to(equal("2")) 136 | h.add("2") 137 | 138 | expect(h.navigateHistory(direction: .previous)).to(equal("2")) 139 | expect(h.navigateHistory(direction: .previous)).to(equal("1")) 140 | } 141 | 142 | // MARK: - Saving and Loading 143 | 144 | func testHistorySavesToFile() { 145 | let h = History() 146 | 147 | h.add("Test 1") 148 | h.add("Test 2") 149 | h.add("Test 3") 150 | 151 | let tempFile = "/tmp/ln_history_save_test.txt" 152 | 153 | expect(try h.save(toFile: tempFile)).notTo(throwError()) 154 | 155 | let fileContents: String 156 | 157 | do { 158 | fileContents = try String(contentsOfFile: tempFile, encoding: .utf8) 159 | } catch { 160 | XCTFail("Loading file should not throw exception") 161 | return 162 | } 163 | 164 | // Reading the file should yield the same lines as input 165 | let items = fileContents.split(separator: "\n") 166 | 167 | expect(items).to(equal(["Test 1", "Test 2", "Test 3"])) 168 | } 169 | 170 | func testHistoryLoadsFromFile() { 171 | let h = History() 172 | 173 | let tempFile = "/tmp/ln_history_load_test.txt" 174 | 175 | do { 176 | try "Test 1\nTest 2\nTest 3".write(toFile: tempFile, atomically: true, encoding: .utf8) 177 | } catch { 178 | XCTFail("Writing file should not throw exception") 179 | } 180 | 181 | expect(try h.load(fromFile: tempFile)).toNot(throwError()) 182 | 183 | expect(h.historyItems).to(haveCount(3)) 184 | expect(h.historyItems).to(equal(["Test 1", "Test 2", "Test 3"])) 185 | } 186 | 187 | func testHistoryLoadingRespectsMaxLength() { 188 | let h = History() 189 | h.maxLength = 2 190 | 191 | let tempFile = "/tmp/ln_history_load_test.txt" 192 | 193 | do { 194 | try "Test 1\nTest 2\nTest 3".write(toFile: tempFile, atomically: true, encoding: .utf8) 195 | } catch { 196 | XCTFail("Writing file should not throw exception") 197 | } 198 | 199 | expect(try h.load(fromFile: tempFile)).toNot(throwError()) 200 | 201 | expect(h.historyItems).to(haveCount(2)) 202 | expect(h.historyItems).to(equal(["Test 2", "Test 3"])) 203 | } 204 | 205 | } 206 | 207 | #if os(Linux) || os(FreeBSD) 208 | extension HistoryTests { 209 | static var allTests: [(String, (HistoryTests) -> () throws -> Void)] { 210 | return [ 211 | ("testHistoryAddItem", testHistoryAddItem), 212 | ("testHistoryDoesNotAddDuplicatedLines", testHistoryDoesNotAddDuplicatedLines), 213 | ("testHistoryHonorsMaxLength", testHistoryHonorsMaxLength), 214 | ("testHistoryRemovesEntriesWhenMaxLengthIsSet", testHistoryRemovesEntriesWhenMaxLengthIsSet), 215 | ("testHistoryNavigationReturnsNilWhenHistoryEmpty", testHistoryNavigationReturnsNilWhenHistoryEmpty), 216 | ("testHistoryNavigationReturnsSingleItemWhenHistoryHasOneItem", testHistoryNavigationReturnsSingleItemWhenHistoryHasOneItem), 217 | ("testHistoryStopsAtBeginning", testHistoryStopsAtBeginning), 218 | ("testHistoryNavigationStopsAtEnd", testHistoryNavigationStopsAtEnd), 219 | ("testHistoryNavigationAfterAddingDuplicateLines", testHistoryNavigationAfterAddingDuplicateLines), 220 | ("testHistorySavesToFile", testHistorySavesToFile), 221 | ("testHistoryLoadsFromFile", testHistoryLoadsFromFile), 222 | ("testHistoryLoadingRespectsMaxLength", testHistoryLoadingRespectsMaxLength) 223 | ] 224 | } 225 | } 226 | #endif 227 | -------------------------------------------------------------------------------- /images/completion.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andybest/linenoise-swift/cbf0a35c6e159e4fe6a03f76c8a17ef08e907b0e/images/completion.gif -------------------------------------------------------------------------------- /images/hints.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andybest/linenoise-swift/cbf0a35c6e159e4fe6a03f76c8a17ef08e907b0e/images/hints.gif --------------------------------------------------------------------------------