diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..038b667 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +build/ +DerivedData + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata + +## Other +*.xccheckout +*.moved-aside +*.xcuserstate +*.xcscmblueprint + +## Obj-C/Swift specific +*.hmap +*.ipa + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +.build +Packages + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build + +# macOS +.DS_Store diff --git a/Sources/CommandLineKit/LineReader.swift b/Sources/CommandLineKit/LineReader.swift index b0ec5c9..e3b5adc 100644 --- a/Sources/CommandLineKit/LineReader.swift +++ b/Sources/CommandLineKit/LineReader.swift @@ -10,18 +10,18 @@ // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are met: -// +// // * Redistributions of source code must retain the above copyright notice, // this list of conditions and the following disclaimer. -// +// // * Redistributions in binary form must reproduce the above copyright notice, // this list of conditions and the following disclaimer in the documentation // and/or other materials provided with the distribution. -// +// // * Neither the name of the copyright holder nor the names of its contributors // may be used to endorse or promote products derived from this software without // specific prior written permission. -// +// // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE @@ -32,44 +32,44 @@ // ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS // SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -// +// import Foundation public class LineReader { - + /// Does this terminal support this line reader? public let termSupported: Bool - + /// Terminal type public let currentTerm: String - + /// Does the terminal support colors? public let fullColorSupport: Bool - + /// If false (the 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 true, all /// history edits will be preserved. public var preserveHistoryEdits = false - + /// The history of previous line reads private var history: LineReaderHistory - + /// Temporary line read buffer to handle browsing of histories private var tempBuf: String? - + /// A callback for handling line completions private var completionCallback: ((String) -> [String])? - + /// A callback for handling hints private var hintsCallback: ((String) -> (String, TextProperties)?)? - + /// A POSIX file handle for the input private let inputFile: Int32 - + /// A POSIX file handle for the output private let outputFile: Int32 - + /// Initializer public init?(inputFile: Int32 = STDIN_FILENO, outputFile: Int32 = STDOUT_FILENO, @@ -88,11 +88,11 @@ public class LineReader { self.completionCallback = completionCallback self.hintsCallback = hintsCallback } - + public static var supportedByTerminal: Bool { return LineReader.supportedBy(terminal: Terminal.current) } - + public static func supportedBy(terminal: String) -> Bool { switch terminal { case "", "xcode", "dumb", "cons25", "emacs": @@ -101,42 +101,42 @@ public class LineReader { return true } } - + /// Adds a string to the history buffer. public func addHistory(_ item: String) { self.history.add(item) } - + /// Adds a callback for tab completion. The callback is taking the current text and returning /// an array of Strings containing possible completions. public func setCompletionCallback(_ callback: @escaping (String) -> [String]) { self.completionCallback = callback } - + /// Adds a callback for hints as you type. The callback is taking the current text and /// optionally returning the hint and a tuple of RGB colours for the hint text. public func setHintsCallback(_ callback: @escaping (String) -> (String, TextProperties)?) { self.hintsCallback = callback } - + /// Loads history from a file and appends it to the current history buffer. This method can /// throw an error if the file cannot be found or loaded. public func loadHistory(fromFile path: String) throws { try self.history.load(fromFile: path) } - + /// Saves history to a file. This method can throw an error if the file cannot be written to. public func saveHistory(toFile path: String) throws { try self.history.save(toFile: path) } - + /// 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. Setting the maximum length of history to 0 /// (the default) will keep unlimited items in history. public func setHistoryMaxLength(_ historyMaxLength: UInt) { self.history.maxLength = historyMaxLength } - + /// Clears the screen. This method can throw an error if the terminal cannot be written to. public func clearScreen() throws { if self.termSupported { @@ -144,7 +144,7 @@ public class LineReader { try self.output(text: AnsiCodes.clearScreen) } } - + /// The main function of LineReader. This method shows a prompt to the user at the beginning /// of the line and reads the input from the user, returning it as a string. The method can /// throw an error if the terminal cannot be written to. @@ -179,7 +179,7 @@ public class LineReader { throw LineReaderError.EOF } } - + private func readLineSupported(prompt: String, maxCount: Int?, strippingNewline: Bool, @@ -215,7 +215,7 @@ public class LineReader { } return strippingNewline ? line : line + "\n" } - + private func completeLine(editState: EditState) throws -> UInt8? { guard let completionCallback = self.completionCallback else { return nil @@ -260,7 +260,7 @@ public class LineReader { } } } - + private func handleCharacter(_ ch: UInt8, editState: EditState) throws -> String? { switch ch { case ControlCharacters.Enter.rawValue: @@ -357,7 +357,7 @@ public class LineReader { } return nil } - + private func handleEscapeCode(editState: EditState) throws { let fst = self.readCharacter() switch fst { @@ -450,7 +450,7 @@ public class LineReader { break } } - + private var cursorColumn: Int? { do { try self.output(text: AnsiCodes.cursorLocation) @@ -483,7 +483,7 @@ public class LineReader { } return Int(String(rowCol[1])) } - + private var numColumns: Int { var winSize = winsize() if ioctl(1, UInt(TIOCGWINSZ), &winSize) == -1 || winSize.ws_col == 0 { @@ -492,11 +492,11 @@ public class LineReader { return Int(winSize.ws_col) } } - + /// This constant is unfortunately not defined right now for usage in Swift; it is specific /// to macOS. Thus, this code is not portable! private static let FIONREAD: UInt = 0x4004667f - + private var bytesAvailable: Int { var available: Int = 0 guard ioctl(self.inputFile, LineReader.FIONREAD, &available) >= 0 else { @@ -504,7 +504,7 @@ public class LineReader { } return available } - + private func updateCursorPos(editState: EditState) throws { if editState.requiresMatching() { try self.refreshLine(editState: editState) @@ -519,7 +519,7 @@ public class LineReader { try self.output(text: commandBuf) } } - + private func refreshLine(editState: EditState, decorate: Bool = true) throws { let cursorWidth = editState.cursorWidth let numColumns = self.numColumns @@ -554,7 +554,7 @@ public class LineReader { AnsiCodes.cursorForward(cursorCols) try self.output(text: commandBuf) } - + private func readByte() -> UInt8? { var input: UInt8 = 0 if read(self.inputFile, &input, 1) == 0 { @@ -562,19 +562,19 @@ public class LineReader { } return input } - + private func forceReadByte() -> UInt8 { var input: UInt8 = 0 _ = read(self.inputFile, &input, 1) return input } - + private func readCharacter() -> Character? { var input: UInt8 = 0 _ = read(self.inputFile, &input, 1) return Character(UnicodeScalar(input)) } - + private func ringBell() { do { try self.output(character: ControlCharacters.Bell.character) @@ -582,21 +582,21 @@ public class LineReader { // ignore failure } } - + private func output(character: ControlCharacters) throws { try self.output(character: character.character) } - + private func output(character: Character) throws { try self.output(text: String(character)) } - + private func output(text: String) throws { if write(outputFile, text, text.utf8.count) == -1 { throw LineReaderError.generalError("Unable to write to output") } } - + private func setBuffer(editState: EditState, new buffer: String) throws { if editState.setBuffer(buffer) { _ = editState.moveEnd() @@ -605,7 +605,7 @@ public class LineReader { self.ringBell() } } - + private func moveLeft(editState: EditState) throws { if editState.moveLeft() { try self.updateCursorPos(editState: editState) @@ -613,7 +613,7 @@ public class LineReader { self.ringBell() } } - + private func moveRight(editState: EditState) throws { if editState.moveRight() { try self.updateCursorPos(editState: editState) @@ -621,7 +621,7 @@ public class LineReader { self.ringBell() } } - + private func moveHome(editState: EditState) throws { if editState.moveHome() { try self.updateCursorPos(editState: editState) @@ -629,7 +629,7 @@ public class LineReader { self.ringBell() } } - + private func moveEnd(editState: EditState) throws { if editState.moveEnd() { try self.updateCursorPos(editState: editState) @@ -637,7 +637,7 @@ public class LineReader { self.ringBell() } } - + private func moveToWordStart(editState: EditState) throws { if editState.moveToWordStart() { try self.updateCursorPos(editState: editState) @@ -645,7 +645,7 @@ public class LineReader { self.ringBell() } } - + private func moveToWordEnd(editState: EditState) throws { if editState.moveToWordEnd() { try self.updateCursorPos(editState: editState) @@ -653,7 +653,7 @@ public class LineReader { self.ringBell() } } - + private func deleteCharacter(editState: EditState) throws { if editState.deleteCharacter() { try self.refreshLine(editState: editState) @@ -661,7 +661,7 @@ public class LineReader { self.ringBell() } } - + private func moveHistory(editState: EditState, direction: LineReaderHistory.HistoryDirection) throws { // If we're at the end of history (editing the current line), push it into a temporary @@ -679,7 +679,7 @@ public class LineReader { self.ringBell() } } - + private func refreshHints(editState: EditState) throws -> String { guard let hintsCallback = self.hintsCallback, let (hint, properties) = hintsCallback(editState.buffer) else { @@ -692,7 +692,7 @@ public class LineReader { return properties.apply(to: hint) + AnsiCodes.origTermColor } } - + private func withRawMode(body: () throws -> ()) throws { var originalTermios: termios = termios() defer { @@ -702,10 +702,10 @@ public class LineReader { throw LineReaderError.generalError("could not get term attributes") } var raw = originalTermios - raw.c_iflag &= ~UInt(BRKINT | ICRNL | INPCK | ISTRIP | IXON) - raw.c_oflag &= ~UInt(OPOST) - raw.c_cflag |= UInt(CS8) - raw.c_lflag &= ~UInt(ECHO | ICANON | IEXTEN | ISIG) + raw.c_iflag &= ~UInt32(BRKINT | ICRNL | INPCK | ISTRIP | IXON) + raw.c_oflag &= ~UInt32(OPOST) + raw.c_cflag |= UInt32(CS8) + raw.c_lflag &= ~UInt32(ECHO | ICANON | IEXTEN | ISIG) // VMIN = 16 raw.c_cc.16 = 1 guard tcsetattr(self.inputFile, TCSADRAIN, &raw) >= 0 else { diff --git a/Sources/CommandLineKitDemo/LinuxMain.swift b/Sources/CommandLineKitDemo/LinuxMain.swift new file mode 100644 index 0000000..71cd414 --- /dev/null +++ b/Sources/CommandLineKitDemo/LinuxMain.swift @@ -0,0 +1,90 @@ +// +// main.swift +// CommandLineKitDemo +// +// Created by Matthias Zenger on 08/04/2018. +// Copyright © 2018 Google LLC +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// * Neither the name of the copyright holder nor the names of its contributors +// may be used to endorse or promote products derived from this software without +// specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +// ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// + +import Foundation +import CommandLineKit + +print("Detected terminal: \(Terminal.current)") +print(Terminal.fullColorSupport ? "Full color support" : "No color support") +print(LineReader.supportedByTerminal ? "LineReader support" : "No LineReader support") + +if let ln = LineReader() { + ln.setCompletionCallback { currentBuffer in + let completions = [ + "Hello, world!", + "Hello, Linenoise!", + "Swift is Awesome!" + ] + return completions.filter { $0.hasPrefix(currentBuffer) } + } + ln.setHintsCallback { currentBuffer in + let hints = [ + "Carpe Diem", + "Lorem Ipsum", + "Swift is Awesome!" + ] + let filtered = hints.filter { $0.hasPrefix(currentBuffer) } + if let hint = filtered.first { + let hintText = String(hint.dropFirst(currentBuffer.count)) + return (hintText, TextColor.grey.properties) + } else { + return nil + } + } + do { + try ln.clearScreen() + } catch { + print(error) + } + print("Type 'exit' to quit") + var done = false + while !done { + do { + let output = try ln.readLine(prompt: "> ", + maxCount: 200, + promptProperties: TextProperties(.green, nil, .bold), + readProperties: TextProperties(.blue, nil), + parenProperties: TextProperties(.red, nil, .bold)) + print("\nOutput: \(output)") + ln.addHistory(output) + if output == "exit" { + break + } + } catch LineReaderError.CTRLC { + print("\nCaptured CTRL+C. Quitting.") + done = true + } catch { + print(error) + } + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift new file mode 100644 index 0000000..e69de29