From 7f87ab50d6cea8366412ad33b9c4a2dafd648347 Mon Sep 17 00:00:00 2001 From: Ian McDowell Date: Sat, 10 Feb 2018 16:50:30 -0800 Subject: [PATCH 01/18] Added TerminalCursor, more control character recognition --- OpenTerm.xcodeproj/project.pbxproj | 16 ++ .../Parsing & Formatting/ANSITextState.swift | 2 +- .../Util/Parsing & Formatting/Parser.swift | 184 ++++++++++++++---- OpenTerm/Util/Terminal/TerminalBuffer.swift | 146 ++++++++++++++ OpenTerm/Util/Terminal/TerminalCursor.swift | 147 ++++++++++++++ OpenTerm/View/TerminalTextView.swift | 17 +- OpenTerm/View/TerminalView.swift | 21 +- 7 files changed, 467 insertions(+), 66 deletions(-) create mode 100644 OpenTerm/Util/Terminal/TerminalBuffer.swift create mode 100644 OpenTerm/Util/Terminal/TerminalCursor.swift diff --git a/OpenTerm.xcodeproj/project.pbxproj b/OpenTerm.xcodeproj/project.pbxproj index 2443d92a..7d78e781 100644 --- a/OpenTerm.xcodeproj/project.pbxproj +++ b/OpenTerm.xcodeproj/project.pbxproj @@ -32,12 +32,14 @@ 3CA32105201FFC1300974B5F /* ScriptEditViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA32104201FFC1300974B5F /* ScriptEditViewController.swift */; }; 3CA3210920211D5600974B5F /* ScriptExecutorCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA3210820211D5600974B5F /* ScriptExecutorCommand.swift */; }; 3CA3210B20212D4200974B5F /* CommandExecutionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA3210A20212D4200974B5F /* CommandExecutionContext.swift */; }; + 3CD59E63202EFE18002298B4 /* TerminalCursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD59E62202EFE17002298B4 /* TerminalCursor.swift */; }; 3CE5764320225E1B00760E43 /* HistoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE5764220225E1B00760E43 /* HistoryManager.swift */; }; 3CE5764820226A1500760E43 /* ANSITextState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE5764720226A1500760E43 /* ANSITextState.swift */; }; 3CE57680202A529200760E43 /* TerminalTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C905FC42025CAEC0084BA63 /* TerminalTabViewController.swift */; }; 3CE57681202A52ED00760E43 /* TerminalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE3808571FD9BFB600393EB8 /* TerminalViewController.swift */; }; 3CE5769E202A7EC500760E43 /* Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE5769D202A7EC500760E43 /* Parser.swift */; }; 3CE576A0202A874C00760E43 /* OutputSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE5769F202A874C00760E43 /* OutputSanitizer.swift */; }; + 3CE576AA202E61DE00760E43 /* TerminalBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE576A9202E61DE00760E43 /* TerminalBuffer.swift */; }; 5A38CC73C499E1A878353871 /* Pods_OpenTerm.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC01604DAC695ABD64544260 /* Pods_OpenTerm.framework */; }; BE000C081FEC4F6C00D06B91 /* ios_system.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE3768EE1FEC4DCE00D5A2D1 /* ios_system.framework */; settings = {ATTRIBUTES = (Required, ); }; }; BE000C091FEC4F6F00D06B91 /* ios_system.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE3768EE1FEC4DCE00D5A2D1 /* ios_system.framework */; settings = {ATTRIBUTES = (Required, ); }; }; @@ -124,10 +126,12 @@ 3CA32104201FFC1300974B5F /* ScriptEditViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptEditViewController.swift; sourceTree = ""; }; 3CA3210820211D5600974B5F /* ScriptExecutorCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptExecutorCommand.swift; sourceTree = ""; }; 3CA3210A20212D4200974B5F /* CommandExecutionContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandExecutionContext.swift; sourceTree = ""; }; + 3CD59E62202EFE17002298B4 /* TerminalCursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TerminalCursor.swift; path = /Volumes/Development/OpenSource/Forks/terminal/OpenTerm/Util/Terminal/TerminalCursor.swift; sourceTree = ""; }; 3CE5764220225E1B00760E43 /* HistoryManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HistoryManager.swift; sourceTree = ""; }; 3CE5764720226A1500760E43 /* ANSITextState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ANSITextState.swift; sourceTree = ""; }; 3CE5769D202A7EC500760E43 /* Parser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parser.swift; sourceTree = ""; }; 3CE5769F202A874C00760E43 /* OutputSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputSanitizer.swift; sourceTree = ""; }; + 3CE576A9202E61DE00760E43 /* TerminalBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalBuffer.swift; sourceTree = ""; }; 448CC7691FD84EB5D2C24705 /* Pods-OpenTerm.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OpenTerm.debug.xcconfig"; path = "Pods/Target Support Files/Pods-OpenTerm/Pods-OpenTerm.debug.xcconfig"; sourceTree = ""; }; 83138AA9A57F46310B4DFF53 /* Pods_Terminal.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Terminal.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BE165407201909030067EC92 /* xCallBackUrl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = xCallBackUrl.swift; sourceTree = ""; }; @@ -257,6 +261,15 @@ path = Scripting; sourceTree = ""; }; + 3CD59E61202EFDFA002298B4 /* Terminal */ = { + isa = PBXGroup; + children = ( + 3CD59E62202EFE17002298B4 /* TerminalCursor.swift */, + 3CE576A9202E61DE00760E43 /* TerminalBuffer.swift */, + ); + path = Terminal; + sourceTree = ""; + }; 3CE5764120225E1B00760E43 /* History */ = { isa = PBXGroup; children = ( @@ -380,6 +393,7 @@ BEA8E28F2001346D00002475 /* Util */ = { isa = PBXGroup; children = ( + 3CD59E61202EFDFA002298B4 /* Terminal */, 3CE576442022607000760E43 /* Parsing & Formatting */, 3C406E1B2020987B005F97C4 /* AutoComplete */, 3C406E1820207CDA005F97C4 /* Execution */, @@ -621,10 +635,12 @@ F4602B49200A63FC009D0547 /* UserDefaults+UIColor.swift in Sources */, 28CDA426202444CC0055206D /* BookmarkViewController.swift in Sources */, 3CA32105201FFC1300974B5F /* ScriptEditViewController.swift in Sources */, + 3CE576AA202E61DE00760E43 /* TerminalBuffer.swift in Sources */, 3C2E4374201EF67C00E4254A /* TerminalView+AutoComplete.swift in Sources */, BEECFF391FFEC187009608B3 /* SettingsViewController.swift in Sources */, F456629E200B9BC500C574AA /* ColorDisplayView.swift in Sources */, BE165408201909040067EC92 /* xCallBackUrl.swift in Sources */, + 3CD59E63202EFE18002298B4 /* TerminalCursor.swift in Sources */, BEA499261FD9C4D7001B9B9D /* DocumentManager.swift in Sources */, BE505066201E5ED900CDFC60 /* Share.swift in Sources */, 3CE5764820226A1500760E43 /* ANSITextState.swift in Sources */, diff --git a/OpenTerm/Util/Parsing & Formatting/ANSITextState.swift b/OpenTerm/Util/Parsing & Formatting/ANSITextState.swift index e62e13d8..23dc79ce 100644 --- a/OpenTerm/Util/Parsing & Formatting/ANSITextState.swift +++ b/OpenTerm/Util/Parsing & Formatting/ANSITextState.swift @@ -175,7 +175,7 @@ enum ANSIFontState: Int { struct ANSITextState { var foregroundColor: UIColor = UserDefaultsController.shared.terminalTextColor - var backgroundColor: UIColor = UserDefaultsController.shared.terminalBackgroundColor + var backgroundColor: UIColor = .clear var isUnderlined: Bool = false var isStrikethrough: Bool = false var font: UIFont = ANSITextState.font(fromTraits: []) diff --git a/OpenTerm/Util/Parsing & Formatting/Parser.swift b/OpenTerm/Util/Parsing & Formatting/Parser.swift index 3bf0570a..2deb6de2 100644 --- a/OpenTerm/Util/Parsing & Formatting/Parser.swift +++ b/OpenTerm/Util/Parsing & Formatting/Parser.swift @@ -8,8 +8,30 @@ import Foundation +/// Protocol to receive notifications when the parser finds interesting things in the data that it's processing. protocol ParserDelegate: class { + + /// When an attributed string is found, it is passed into this method. + /// The attributes will be determined based on the current ANSI text state. + /// This string will not contain control characters, newlines, carriage returns, etc. func parser(_ parser: Parser, didReceiveString string: NSAttributedString) + + /// A carriage return was found. The cursor position should be updated. + func parserDidReceiveCarriageReturn(_ parser: Parser) + + /// A newline character was found. The cursor position should be updated. + func parserDidReceiveNewLine(_ parser: Parser) + + /// A backspace character was found. The cursor position should be updated. + func parserDidReceiveBackspace(_ parser: Parser) + + /// The cursor was moved in the given direction, `count` number of times. + func parser(_ parser: Parser, didMoveCursorInDirection direction: TerminalCursor.Direction, count: Int) + + /// The cursor was moved to a given position (0.. (decoded: NSAttributedString, didEnd: Bool) { + private func decodeUTF8(fromData data: Data, buffer: inout Data) -> Bool { let data = buffer + data // Parse what we can from the previous leftover and the new data. - let (str, leftover, didEnd) = self.decodeUTF8(fromData: data) + let (leftover, didEnd) = self.decodeUTF8(fromData: data) // There are two reasons we could get leftover data: // - An invalid character was found in the middle of the string @@ -111,7 +138,7 @@ class Parser { buffer = Data() } - return (str, didEnd) + return didEnd } /// Decode UTF-8 string from the given data. @@ -119,22 +146,17 @@ class Parser { /// which is necessary since data can come in arbitrarily-sized chunks of bytes, with characters split /// across multiple chunks. /// The first time decoding fails, all of the rest of the data will be returned. - private func decodeUTF8(fromData data: Data) -> (decoded: NSAttributedString, remaining: Data, didEnd: Bool) { + private func decodeUTF8(fromData data: Data) -> (remaining: Data, didEnd: Bool) { let byteArray = [UInt8](data) var utf8Decoder = UTF8() - let str = NSMutableAttributedString() var byteIterator = byteArray.makeIterator() var decodedByteCount = 0 var didEnd: Bool = false Decode: while !didEnd { switch utf8Decoder.decode(&byteIterator) { case .scalarValue(let v): - var output: NSAttributedString? = nil - (output, didEnd) = self.handle(Character(v)) - if let output = output { - str.append(output) - } + didEnd = self.handle(Character(v)) decodedByteCount += UTF8.encode(v)!.count case .emptyInput, .error: break Decode @@ -142,13 +164,13 @@ class Parser { } let remaining = Data.init(bytes: byteArray.suffix(from: decodedByteCount)) - return (str, remaining, didEnd) + return (remaining, didEnd) } /// This method is called for each UTF-8 character that is received. /// It should perform state changes based on that character, then /// return an attributed string that renders the character - private func handle(_ character: Character) -> (output: NSAttributedString?, didEnd: Bool) { + private func handle(_ character: Character) -> Bool { // Create a string with the given character let str = String.init(character) @@ -158,14 +180,27 @@ class Parser { guard let code = Code.init(rawValue: str) else { // While in normal mode, unless we found a code, we should return a string using the current // textState's attributes. - return (NSAttributedString.init(string: str, attributes: textState.attributes), false) + self.delegate?.parser(self, didReceiveString: NSAttributedString.init(string: str, attributes: textState.attributes)) + return false } switch code { case .endOfTransmission: // Ended transmission, return immediately. - return (nil, true) + return true case .escape: self.state = .escape + case .carriageReturn: + self.delegate?.parserDidReceiveCarriageReturn(self) + case .newLine: + self.delegate?.parserDidReceiveNewLine(self) + case .backspace: + self.delegate?.parserDidReceiveBackspace(self) + case .shiftIn: + // TODO: Support different character encodings + break + case .shiftOut: + // TODO: Support different character encodings + break } case .escape: if let escapeCode = Code.EscapeCode.init(rawValue: str) { @@ -173,9 +208,6 @@ class Parser { case .controlSequenceIntroducer: // We found a CSI sequence. self.state = .csiSequence(parameters: "") - default: - // Ignore code and return to normal - self.state = .normal } } else { // Last character was escape, but we didn't find a recognizable code. Return to normal. @@ -184,22 +216,94 @@ class Parser { case .csiSequence(let parameters): // We are in the middle of parsing a csi sequence - if let suffix = Code.ControlSequenceSuffix.init(rawValue: str) { - switch suffix { - case .selectGraphicRendition: - textState.parse(escapeCodes: parameters) - default: - break + // The following ranges are from: https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_sequences + + // ASCII 0–9:;<=>? + let parameterRange: CountableClosedRange = 0x30...0x3F + + // ASCII space and !"#$%&'()*+,-./ + let intermediateBytesRange: CountableClosedRange = 0x20...0x2F + + // ASCII @A–Z[\]^_`a–z{|}~ + let finalByteRange: CountableClosedRange = 0x40...0x7E + + // Get scalar value from character, then find which range it fits in + let scalar = character.unicodeScalars.first!.value + if parameterRange.contains(scalar) || intermediateBytesRange.contains(scalar) { + self.state = .csiSequence(parameters: parameters + str) + } else if finalByteRange.contains(scalar) { + if let suffix = Code.ControlSequenceSuffix.init(rawValue: str) { + + // Most parameters are a single number, and if missing, default to 1. + // so for convenience, parse that now. + let intValue = Int(parameters) ?? 1 + + switch suffix { + case .selectGraphicRendition: + // Put the parameters into the text state, which updates its attributes. + textState.parse(escapeCodes: parameters) + case .cursorUp: + self.delegate?.parser(self, didMoveCursorInDirection: .up, count: intValue) + case .cursorDown: + self.delegate?.parser(self, didMoveCursorInDirection: .down, count: intValue) + case .cursorForward: + self.delegate?.parser(self, didMoveCursorInDirection: .right, count: intValue) + case .cursorBack: + self.delegate?.parser(self, didMoveCursorInDirection: .left, count: intValue) + case .cursorNextLine: + // Moves cursor to beginning of the line n (default 1) lines down + // Combine the beginning of line and down directions to achieve this + self.delegate?.parser(self, didMoveCursorInDirection: .beginningOfLine, count: 1) + self.delegate?.parser(self, didMoveCursorInDirection: .down, count: intValue) + case .cursorPreviousLine: + // Moves cursor to beginning of the line n (default 1) lines up + // Combine the beginning of line and up directions to achieve this + self.delegate?.parser(self, didMoveCursorInDirection: .beginningOfLine, count: 1) + self.delegate?.parser(self, didMoveCursorInDirection: .up, count: intValue) + case .cursorHorizontalAbsolute: + // Cursor should move to the intValue'th column. + // Delegate value is 0-based, and this is 1-based, so subtract 1. + self.delegate?.parser(self, didMoveCursorTo: intValue - 1, onAxis: .x) + case .cursorPosition, .horizontalVerticalPosition: + // TODO: Set x,y cursor position (1-based) + break + case .eraseInDisplay: + // TODO: Clear part of the screen + break + case .eraseInLine: + // TODO: Erase part of line without changing cursor position + break + case .scrollUp: + // TODO: Scroll up + break + case .scrollDown: + // TODO: Scroll down + break + case .auxPortControl: + // Not supported + break + case .deviceStatusReport: + // TODO: Send x,y cursor position to application + break + case .saveCursorPosition: + // TODO: Save cursor + break + case .restoreCursorPosition: + // TODO: Restore cursor + break + } } + // The CSI sequence is done, so return to normal state. self.state = .normal } else { - self.state = .csiSequence(parameters: parameters + str) + // Character was not in any acceptable range, so ignore it and exit csi state + self.state = .normal } } // If we made it here, that means that we're in the middle of handling states. // No characters are output during this time. - return (nil, false) + return false } } diff --git a/OpenTerm/Util/Terminal/TerminalBuffer.swift b/OpenTerm/Util/Terminal/TerminalBuffer.swift new file mode 100644 index 00000000..fdf2691b --- /dev/null +++ b/OpenTerm/Util/Terminal/TerminalBuffer.swift @@ -0,0 +1,146 @@ +// +// TerminalBuffer.swift +// OpenTerm +// +// Created by Ian McDowell on 2/9/18. +// Copyright © 2018 Silver Fox. All rights reserved. +// + +import UIKit + +protocol TerminalBufferDelegate: class { + + /// An End of Text message was received, which means the current command finished. + func terminalBufferDidReceiveETX() +} + +/// The terminal buffer is the entity that passes command output to a UITextView. +/// Here is where escape codes are handled, and data is stored. +/// +/// A buffer manages contains the following important things: +/// - Storage => NSTextStorage (NSMutableAttributedString subclass), that contains all text in the terminal +/// - Parsers => Parser objects that convert Data to NSAttributedString +/// - Cursor => A cursor pointing to a location in the storage. As new data comes in, it is appended at the cursor position. +/// +/// The buffer exposes an NSTextContainer, which a UITextView should add to display terminal contents. +/// Most changes will flow from NSTextStorage -> NSLayoutManager -> NSTextContainer -> UITextView automatically. +/// Additional notifications about changes will be sent to the `delegate` of the terminal buffer. +class TerminalBuffer { + + weak var delegate: TerminalBufferDelegate? + + private let storage: NSTextStorage + private let layoutManager: NSLayoutManager + let textContainer: NSTextContainer + + private let stdoutParser: Parser + private let stderrParser: Parser + + private var cursor: TerminalCursor + + init() { + storage = NSTextStorage() + layoutManager = NSLayoutManager() + textContainer = NSTextContainer() + + stdoutParser = Parser() + stderrParser = Parser() + + cursor = .zero + + storage.addLayoutManager(layoutManager) + layoutManager.addTextContainer(textContainer) + + stdoutParser.delegate = self + stderrParser.delegate = self + } + + /// Reset the state of the parsers & the cursor position + func reset() { + stdoutParser.reset() + stderrParser.reset() + cursor = .zero + } + + /// Move the cursor to the end of the storage + func moveCursorToEnd() { + cursor.move(.endOfString, in: storage) + } + + /// Add raw data from stdout + func add(stdout: Data) { + stdoutParser.parse(stdout) + } + + /// Add raw data from stderr + func add(stderr: Data) { + stderrParser.parse(stderr) + } + + /// Insert the given attributed string into the storage after the current cursor position. + /// Characters after the cursor position that are in the way are replaced by the contents of the string. + /// The attributed string is expected to not contain control characters or newlines. + /// The cursor is moved to the end of the added text. + private func insert(_ attributedString: NSAttributedString) { + // Get cursor position as distance from start + let insertionPoint = cursor.offset + assert(insertionPoint <= storage.length, "Insertion point must be within the storage's size") + + // Get the distance from the cursor to the end of the string + let distanceToEnd = cursor.distanceToEndOfLine(in: storage) + + // Create an NSRange for replacing characters. + // It starts at the insertion point, and has length of whichever one is smaller: + // - The length of the inserted string + // - The distance from the insertion point to the end + let range = NSRange.init(location: insertionPoint, length: min(distanceToEnd, attributedString.length)) + + self.storage.replaceCharacters(in: range, with: attributedString) + + // Move cursor right by the number of characters in the inserted string + for _ in 0.. previousNewLine { + self.x -= 1 + self.offset -= 1 + } + case .right: + let nextNewLine = self.indexOfNextNewline(from: offset, in: string) + // Only move right until we hit a newline + if offset < nextNewLine { + self.x += 1 + self.offset += 1 + } + case .beginningOfLine: + self.x = 0 + self.offset = string.distance(from: string.startIndex, to: self.indexAfterPreviousNewline(from: offset, in: string)) + case .endOfString: + let components = string.components(separatedBy: newlineCharacterSet) + self.offset = string.count + self.y = components.count - 1 + self.x = components.last?.count ?? 0 + } + } + + /// Set the position of the cursor on the given axis. + mutating func set(_ axis: Axis, to position: Int, in storage: NSTextStorage) { + dispatchPrecondition(condition: .onQueue(.main)) + + let string = storage.string + let offset = string.index(string.startIndex, offsetBy: self.offset) + + switch axis { + case .x: + let beginningOfLine = self.indexAfterPreviousNewline(from: offset, in: string) + let endOfLine = self.indexOfNextNewline(from: offset, in: string) + let lineLength = string.distance(from: beginningOfLine, to: endOfLine) + if position <= lineLength { + self.x = position + self.offset = string.distance(from: string.startIndex, to: string.index(beginningOfLine, offsetBy: position)) + } + case .y: + fatalError("Setting the y axis' position is not currently supported.") + } + } + + /// How far is the current x position from the next newline character? + func distanceToEndOfLine(in storage: NSTextStorage) -> Int { + let string = storage.string + let offset = string.index(string.startIndex, offsetBy: self.offset) + let index = self.indexOfNextNewline(from: offset, in: string) + let distance = string.distance(from: offset, to: index) + return distance + } + + private func indexAfterPreviousNewline(from currentPosition: String.Index, in string: String) -> String.Index { + if let range = rangeOfPreviousNewline(from: currentPosition, in: string) { + return range.upperBound + } + // If none was found, return the beginning of the string + return string.startIndex + } + + private func indexOfNextNewline(from currentPosition: String.Index, in string: String) -> String.Index { + if let range = rangeOfNextNewline(from: currentPosition, in: string) { + return range.lowerBound + } + // If none was found, return the end of the string + return string.endIndex + } + + private func indexAfterNextNewline(from currentPosition: String.Index, in string: String) -> String.Index { + if let range = rangeOfNextNewline(from: currentPosition, in: string) { + return range.upperBound + } + // If none was found, return the end of the string + return string.endIndex + } + + private func rangeOfPreviousNewline(from currentPosition: String.Index, in string: String) -> Range? { + // Find the next newline, starting at the current position and going backwards. + return string.rangeOfCharacter(from: newlineCharacterSet, options: .backwards, range: string.startIndex.. Range? { + // Find the next newline, starting at current position and going forwards. + return string.rangeOfCharacter(from: newlineCharacterSet, options: [], range: currentPosition.. Date: Sat, 10 Feb 2018 17:09:14 -0800 Subject: [PATCH 02/18] A few fixes after merge --- OpenTerm/Util/Parsing & Formatting/Parser.swift | 6 +++++- OpenTerm/View/TerminalView.swift | 10 +++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/OpenTerm/Util/Parsing & Formatting/Parser.swift b/OpenTerm/Util/Parsing & Formatting/Parser.swift index 2deb6de2..96f8c71f 100644 --- a/OpenTerm/Util/Parsing & Formatting/Parser.swift +++ b/OpenTerm/Util/Parsing & Formatting/Parser.swift @@ -42,6 +42,10 @@ class Parser { /// List of constants that are needed for parsing. enum Code: String { case escape = "\u{1B}" + + // The "End of text" control code. It is entered by CTRL+C. + case endOfText = "\u{03}" + // The "End of transmission" control code. Is used to indicate end-of-file on the terminal. case endOfTransmission = "\u{04}" @@ -184,7 +188,7 @@ class Parser { return false } switch code { - case .endOfTransmission: + case .endOfText, .endOfTransmission: // Ended transmission, return immediately. return true case .escape: diff --git a/OpenTerm/View/TerminalView.swift b/OpenTerm/View/TerminalView.swift index e4e6077a..64044c07 100644 --- a/OpenTerm/View/TerminalView.swift +++ b/OpenTerm/View/TerminalView.swift @@ -104,9 +104,8 @@ class TerminalView: UIView { let text = NSMutableAttributedString.init(attributedString: text) OutputSanitizer.sanitize(text.mutableString) - let new = NSMutableAttributedString(attributedString: textView.attributedText ?? NSAttributedString()) - new.append(text) - textView.attributedText = new + textView.textStorage.append(text) + textView.selectedTextRange = textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument) let rect = textView.caretRect(for: textView.endOfDocument) textView.scrollRectToVisible(rect, animated: true) @@ -319,7 +318,6 @@ extension TerminalView: UITextViewDelegate { switch executor.state { case .running: executor.sendInput(text) - return true case .idle: let i = textView.text.distance(from: textView.text.startIndex, to: currentCommandStartIndex) @@ -338,9 +336,11 @@ extension TerminalView: UITextViewDelegate { self.textView.buffer.moveCursorToEnd() delegate?.didEnterCommand(String(input)) } + // Don't enter the \n character + return false } - return true } + return true } func textViewDidChange(_ textView: UITextView) { From 248f75bc5d03561ed8553c24b2ef3bce77560324 Mon Sep 17 00:00:00 2001 From: Ian McDowell Date: Sat, 10 Feb 2018 21:56:33 -0800 Subject: [PATCH 03/18] Added tests for TerminalCursor --- OpenTerm.xcodeproj/project.pbxproj | 4 + OpenTerm/Util/Terminal/TerminalCursor.swift | 14 +- OpenTermTests/TerminalCursorTests.swift | 156 ++++++++++++++++++++ 3 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 OpenTermTests/TerminalCursorTests.swift diff --git a/OpenTerm.xcodeproj/project.pbxproj b/OpenTerm.xcodeproj/project.pbxproj index 7d78e781..72b746a3 100644 --- a/OpenTerm.xcodeproj/project.pbxproj +++ b/OpenTerm.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 3CA3210920211D5600974B5F /* ScriptExecutorCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA3210820211D5600974B5F /* ScriptExecutorCommand.swift */; }; 3CA3210B20212D4200974B5F /* CommandExecutionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA3210A20212D4200974B5F /* CommandExecutionContext.swift */; }; 3CD59E63202EFE18002298B4 /* TerminalCursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD59E62202EFE17002298B4 /* TerminalCursor.swift */; }; + 3CD59E67202FDEBA002298B4 /* TerminalCursorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD59E66202FDEBA002298B4 /* TerminalCursorTests.swift */; }; 3CE5764320225E1B00760E43 /* HistoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE5764220225E1B00760E43 /* HistoryManager.swift */; }; 3CE5764820226A1500760E43 /* ANSITextState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE5764720226A1500760E43 /* ANSITextState.swift */; }; 3CE57680202A529200760E43 /* TerminalTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C905FC42025CAEC0084BA63 /* TerminalTabViewController.swift */; }; @@ -127,6 +128,7 @@ 3CA3210820211D5600974B5F /* ScriptExecutorCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptExecutorCommand.swift; sourceTree = ""; }; 3CA3210A20212D4200974B5F /* CommandExecutionContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandExecutionContext.swift; sourceTree = ""; }; 3CD59E62202EFE17002298B4 /* TerminalCursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TerminalCursor.swift; path = /Volumes/Development/OpenSource/Forks/terminal/OpenTerm/Util/Terminal/TerminalCursor.swift; sourceTree = ""; }; + 3CD59E66202FDEBA002298B4 /* TerminalCursorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCursorTests.swift; sourceTree = ""; }; 3CE5764220225E1B00760E43 /* HistoryManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HistoryManager.swift; sourceTree = ""; }; 3CE5764720226A1500760E43 /* ANSITextState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ANSITextState.swift; sourceTree = ""; }; 3CE5769D202A7EC500760E43 /* Parser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parser.swift; sourceTree = ""; }; @@ -415,6 +417,7 @@ children = ( BEC75BFC202B716600216462 /* OpenTermTests.swift */, BEC75BFE202B716600216462 /* Info.plist */, + 3CD59E66202FDEBA002298B4 /* TerminalCursorTests.swift */, ); path = OpenTermTests; sourceTree = ""; @@ -667,6 +670,7 @@ buildActionMask = 2147483647; files = ( BEC75BFD202B716600216462 /* OpenTermTests.swift in Sources */, + 3CD59E67202FDEBA002298B4 /* TerminalCursorTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/OpenTerm/Util/Terminal/TerminalCursor.swift b/OpenTerm/Util/Terminal/TerminalCursor.swift index 3a2eba78..929fbfe6 100644 --- a/OpenTerm/Util/Terminal/TerminalCursor.swift +++ b/OpenTerm/Util/Terminal/TerminalCursor.swift @@ -47,15 +47,15 @@ struct TerminalCursor { case .down: let nextNewLine = self.indexAfterNextNewline(from: offset, in: string) - // Calculate our new offset. If it goes past the end, this will be nil + // Calculate our new offset. If it goes past the end, this will be nil, and we do nothing if let newOffset = string.index(nextNewLine, offsetBy: x, limitedBy: string.endIndex) { - self.offset = string.distance(from: string.startIndex, to: newOffset) - } else { - self.offset = string.count - // Since we couldn't go a full line down, x will change - self.x = string.distance(from: nextNewLine, to: string.endIndex) + let distance = string.distance(from: string.startIndex, to: newOffset) + if distance != self.offset { + // Only move if offset changed. + self.offset = distance + self.y += 1 + } } - self.y += 1 case .left: let previousNewLine = self.indexAfterPreviousNewline(from: offset, in: string) // Only move left until we hit a newline diff --git a/OpenTermTests/TerminalCursorTests.swift b/OpenTermTests/TerminalCursorTests.swift new file mode 100644 index 00000000..668a8a59 --- /dev/null +++ b/OpenTermTests/TerminalCursorTests.swift @@ -0,0 +1,156 @@ +// +// TerminalCursorTests.swift +// OpenTermTests +// +// Created by Ian McDowell on 2/10/18. +// Copyright © 2018 Silver Fox. All rights reserved. +// + +import XCTest +@testable import OpenTerm + +class TerminalCursorTests: XCTestCase { + + var storage: NSTextStorage! + var cursor: TerminalCursor! + + override func setUp() { + super.setUp() + + cursor = TerminalCursor.zero + storage = NSTextStorage.init(string: "") + } + + override func tearDown() { + super.tearDown() + + } + + // TODO: Test move up + // TODO: Test set y axis + + func testAppendMoveRightThenLeftByLength() { + let str = "hello" + storage.append(NSAttributedString.init(string: str)) + + for _ in 0.. Date: Sat, 10 Feb 2018 22:23:26 -0800 Subject: [PATCH 04/18] Added TerminalBufferTests, Moved GCD helpers into Dispatch+Custom --- OpenTerm.xcodeproj/project.pbxproj | 8 ++ OpenTerm/Util/Dispatch+Custom.swift | 21 +++++ OpenTerm/Util/Terminal/TerminalBuffer.swift | 18 +++-- OpenTerm/View/TerminalView.swift | 13 +--- OpenTermTests/TerminalBufferTests.swift | 85 +++++++++++++++++++++ 5 files changed, 127 insertions(+), 18 deletions(-) create mode 100644 OpenTerm/Util/Dispatch+Custom.swift create mode 100644 OpenTermTests/TerminalBufferTests.swift diff --git a/OpenTerm.xcodeproj/project.pbxproj b/OpenTerm.xcodeproj/project.pbxproj index 72b746a3..95a23fdf 100644 --- a/OpenTerm.xcodeproj/project.pbxproj +++ b/OpenTerm.xcodeproj/project.pbxproj @@ -34,6 +34,8 @@ 3CA3210B20212D4200974B5F /* CommandExecutionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA3210A20212D4200974B5F /* CommandExecutionContext.swift */; }; 3CD59E63202EFE18002298B4 /* TerminalCursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD59E62202EFE17002298B4 /* TerminalCursor.swift */; }; 3CD59E67202FDEBA002298B4 /* TerminalCursorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD59E66202FDEBA002298B4 /* TerminalCursorTests.swift */; }; + 3CD59E6920301329002298B4 /* TerminalBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD59E6820301329002298B4 /* TerminalBufferTests.swift */; }; + 3CD59E6B20301588002298B4 /* Dispatch+Custom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD59E6A20301588002298B4 /* Dispatch+Custom.swift */; }; 3CE5764320225E1B00760E43 /* HistoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE5764220225E1B00760E43 /* HistoryManager.swift */; }; 3CE5764820226A1500760E43 /* ANSITextState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE5764720226A1500760E43 /* ANSITextState.swift */; }; 3CE57680202A529200760E43 /* TerminalTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C905FC42025CAEC0084BA63 /* TerminalTabViewController.swift */; }; @@ -129,6 +131,8 @@ 3CA3210A20212D4200974B5F /* CommandExecutionContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandExecutionContext.swift; sourceTree = ""; }; 3CD59E62202EFE17002298B4 /* TerminalCursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TerminalCursor.swift; path = /Volumes/Development/OpenSource/Forks/terminal/OpenTerm/Util/Terminal/TerminalCursor.swift; sourceTree = ""; }; 3CD59E66202FDEBA002298B4 /* TerminalCursorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCursorTests.swift; sourceTree = ""; }; + 3CD59E6820301329002298B4 /* TerminalBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalBufferTests.swift; sourceTree = ""; }; + 3CD59E6A20301588002298B4 /* Dispatch+Custom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dispatch+Custom.swift"; sourceTree = ""; }; 3CE5764220225E1B00760E43 /* HistoryManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HistoryManager.swift; sourceTree = ""; }; 3CE5764720226A1500760E43 /* ANSITextState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ANSITextState.swift; sourceTree = ""; }; 3CE5769D202A7EC500760E43 /* Parser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parser.swift; sourceTree = ""; }; @@ -408,6 +412,7 @@ BEA499251FD9C4D7001B9B9D /* DocumentManager.swift */, BE9275052013961D00BD2761 /* UserDefaultsController.swift */, 3C905FC920265BC60084BA63 /* StoreReviewPrompter.swift */, + 3CD59E6A20301588002298B4 /* Dispatch+Custom.swift */, ); path = Util; sourceTree = ""; @@ -418,6 +423,7 @@ BEC75BFC202B716600216462 /* OpenTermTests.swift */, BEC75BFE202B716600216462 /* Info.plist */, 3CD59E66202FDEBA002298B4 /* TerminalCursorTests.swift */, + 3CD59E6820301329002298B4 /* TerminalBufferTests.swift */, ); path = OpenTermTests; sourceTree = ""; @@ -641,6 +647,7 @@ 3CE576AA202E61DE00760E43 /* TerminalBuffer.swift in Sources */, 3C2E4374201EF67C00E4254A /* TerminalView+AutoComplete.swift in Sources */, BEECFF391FFEC187009608B3 /* SettingsViewController.swift in Sources */, + 3CD59E6B20301588002298B4 /* Dispatch+Custom.swift in Sources */, F456629E200B9BC500C574AA /* ColorDisplayView.swift in Sources */, BE165408201909040067EC92 /* xCallBackUrl.swift in Sources */, 3CD59E63202EFE18002298B4 /* TerminalCursor.swift in Sources */, @@ -669,6 +676,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3CD59E6920301329002298B4 /* TerminalBufferTests.swift in Sources */, BEC75BFD202B716600216462 /* OpenTermTests.swift in Sources */, 3CD59E67202FDEBA002298B4 /* TerminalCursorTests.swift in Sources */, ); diff --git a/OpenTerm/Util/Dispatch+Custom.swift b/OpenTerm/Util/Dispatch+Custom.swift new file mode 100644 index 00000000..232f171d --- /dev/null +++ b/OpenTerm/Util/Dispatch+Custom.swift @@ -0,0 +1,21 @@ +// +// Dispatch+Custom.swift +// OpenTerm +// +// Created by Ian McDowell on 2/10/18. +// Copyright © 2018 Silver Fox. All rights reserved. +// + +import Foundation + +extension DispatchQueue { + + /// Performs the given block on the main thread, without dispatching if already there. + static func performOnMain(_ block: @escaping () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async(execute: block) + } + } +} diff --git a/OpenTerm/Util/Terminal/TerminalBuffer.swift b/OpenTerm/Util/Terminal/TerminalBuffer.swift index fdf2691b..29fefb4d 100644 --- a/OpenTerm/Util/Terminal/TerminalBuffer.swift +++ b/OpenTerm/Util/Terminal/TerminalBuffer.swift @@ -105,38 +105,42 @@ class TerminalBuffer { } extension TerminalBuffer: ParserDelegate { + // The methods below are performOnMain because: + // Parser delegates are called on the Parser's thread + // TerminalBufferTests call these methods from the main thread, and expect them to happen synchronously. + func parser(_ parser: Parser, didReceiveString string: NSAttributedString) { - DispatchQueue.main.async { + DispatchQueue.performOnMain { self.insert(string) } } func parserDidReceiveCarriageReturn(_ parser: Parser) { - DispatchQueue.main.async { + DispatchQueue.performOnMain { self.cursor.move(.beginningOfLine, in: self.storage) } } func parserDidReceiveNewLine(_ parser: Parser) { - DispatchQueue.main.async { + DispatchQueue.performOnMain { self.storage.append(NSAttributedString.init(string: "\n")) - self.cursor.move(.down, in: self.storage) self.cursor.move(.beginningOfLine, in: self.storage) + self.cursor.move(.down, in: self.storage) } } func parserDidReceiveBackspace(_ parser: Parser) { - DispatchQueue.main.async { + DispatchQueue.performOnMain { // TODO: Is this correct? Should we also modify storage at all? self.cursor.move(.left, in: self.storage) } } func parser(_ parser: Parser, didMoveCursorInDirection direction: TerminalCursor.Direction, count: Int) { - DispatchQueue.main.async { + DispatchQueue.performOnMain { for _ in 0.. Void) { - if Thread.isMainThread { - block() - } else { - DispatchQueue.main.async(execute: block) - } - } - private func appendText(_ text: NSAttributedString) { dispatchPrecondition(condition: .onQueue(.main)) @@ -127,13 +118,13 @@ class TerminalView: UIView { // Appends the given string to the output, and updates the command start index. func writeOutput(_ string: String) { - performOnMain { + DispatchQueue.performOnMain { self.appendText(string) self.currentCommandStartIndex = self.textView.text.endIndex } } func writeOutput(_ string: NSAttributedString) { - performOnMain { + DispatchQueue.performOnMain { self.appendText(string) self.currentCommandStartIndex = self.textView.text.endIndex } diff --git a/OpenTermTests/TerminalBufferTests.swift b/OpenTermTests/TerminalBufferTests.swift new file mode 100644 index 00000000..9514b8d5 --- /dev/null +++ b/OpenTermTests/TerminalBufferTests.swift @@ -0,0 +1,85 @@ +// +// TerminalBufferTests.swift +// OpenTermTests +// +// Created by Ian McDowell on 2/10/18. +// Copyright © 2018 Silver Fox. All rights reserved. +// + +import XCTest +@testable import OpenTerm + +// These tests call the ParserDelegate methods, and intend to avoid sending data to the parser if at all possible. +// Save tests for the Parser for the Parser's test cases. +class TerminalBufferTests: XCTestCase { + + var buffer: TerminalBuffer! + + // Passing delegate methods from a parser require a Parser parameter. Pass an empty one in to get it to compile. + // If the TerminalBuffer ever reads stuff about the parser (it currently does not), this will need a better implementation + // Such as to make the TerminalBuffer's parser non-private + let dummyParser = Parser() + + override func setUp() { + super.setUp() + + buffer = TerminalBuffer() + } + + override func tearDown() { + super.tearDown() + + } + + // MARK: Helper methods + + // Since the buffer doesn't expose its NSTextStorage, we must retrieve it from its public NSTextContainer + private var bufferContents: String { + return buffer.textContainer.layoutManager!.textStorage!.string + } + + // Helper method to send a string to the buffer + private func receiveString(_ string: String) { + buffer.parser(dummyParser, didReceiveString: NSAttributedString.init(string: string)) + } + private func newLine() { + buffer.parserDidReceiveNewLine(dummyParser) + } + private func carriageReturn() { + buffer.parserDidReceiveCarriageReturn(dummyParser) + } + + // MARK: Tests + + func testReceiveString() { + let string = "hello world" + + receiveString(string) + + XCTAssert(bufferContents == string, "Buffer should equal the received string") + } + + func testStringsWithNewLine() { + let line1 = "hello world" + let line2 = "test" + + receiveString(line1) + newLine() + receiveString(line2) + + XCTAssert(bufferContents == line1 + "\n" + line2, "Buffer should equal the text sent in order") + } + + func testStringsWithCarriageReturn() { + let line1pt1 = "replaceme" + let line1pt2 = " test" + let line2 = "123456789" + + receiveString(line1pt1 + line1pt2) + carriageReturn() + receiveString(line2) + + XCTAssert(bufferContents == line2 + line1pt2, "Buffer should equal the second line + leftover first line") + } + +} From 194a57f98c812ac19085cd8d2360a427def95b37 Mon Sep 17 00:00:00 2001 From: Ian McDowell Date: Sat, 10 Feb 2018 22:25:03 -0800 Subject: [PATCH 05/18] Removed absolute path from pbxproj to fix build --- OpenTerm.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenTerm.xcodeproj/project.pbxproj b/OpenTerm.xcodeproj/project.pbxproj index 95a23fdf..b26cf87f 100644 --- a/OpenTerm.xcodeproj/project.pbxproj +++ b/OpenTerm.xcodeproj/project.pbxproj @@ -129,7 +129,7 @@ 3CA32104201FFC1300974B5F /* ScriptEditViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptEditViewController.swift; sourceTree = ""; }; 3CA3210820211D5600974B5F /* ScriptExecutorCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptExecutorCommand.swift; sourceTree = ""; }; 3CA3210A20212D4200974B5F /* CommandExecutionContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandExecutionContext.swift; sourceTree = ""; }; - 3CD59E62202EFE17002298B4 /* TerminalCursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TerminalCursor.swift; path = /Volumes/Development/OpenSource/Forks/terminal/OpenTerm/Util/Terminal/TerminalCursor.swift; sourceTree = ""; }; + 3CD59E62202EFE17002298B4 /* TerminalCursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCursor.swift; sourceTree = ""; }; 3CD59E66202FDEBA002298B4 /* TerminalCursorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCursorTests.swift; sourceTree = ""; }; 3CD59E6820301329002298B4 /* TerminalBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalBufferTests.swift; sourceTree = ""; }; 3CD59E6A20301588002298B4 /* Dispatch+Custom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dispatch+Custom.swift"; sourceTree = ""; }; From b07213447fed770fe7e695d5140f2b13f7b5fe11 Mon Sep 17 00:00:00 2001 From: Ian McDowell Date: Sat, 10 Feb 2018 22:35:26 -0800 Subject: [PATCH 06/18] Removed cyclomatic complexity error for Parser method --- OpenTerm/Util/Parsing & Formatting/Parser.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/OpenTerm/Util/Parsing & Formatting/Parser.swift b/OpenTerm/Util/Parsing & Formatting/Parser.swift index 96f8c71f..9a6685ac 100644 --- a/OpenTerm/Util/Parsing & Formatting/Parser.swift +++ b/OpenTerm/Util/Parsing & Formatting/Parser.swift @@ -174,6 +174,8 @@ class Parser { /// This method is called for each UTF-8 character that is received. /// It should perform state changes based on that character, then /// return an attributed string that renders the character + // + // swiftlint:disable cyclomatic_complexity private func handle(_ character: Character) -> Bool { // Create a string with the given character let str = String.init(character) From 3eaa455cf7685cdf5e93489704762cb233026baa Mon Sep 17 00:00:00 2001 From: Ian McDowell Date: Sun, 11 Feb 2018 17:07:26 -0800 Subject: [PATCH 07/18] Added ParserTests, XCTAssert -> XCTAssertEqual, enabled codecov --- OpenTerm.xcodeproj/project.pbxproj | 4 + .../xcshareddata/xcschemes/OpenTerm.xcscheme | 3 +- .../Controller/TerminalViewController.swift | 2 +- .../Parsing & Formatting/ANSITextState.swift | 40 ++++--- OpenTermTests/ParserTests.swift | 109 ++++++++++++++++++ OpenTermTests/TerminalBufferTests.swift | 6 +- OpenTermTests/TerminalCursorTests.swift | 48 ++++---- 7 files changed, 167 insertions(+), 45 deletions(-) create mode 100644 OpenTermTests/ParserTests.swift diff --git a/OpenTerm.xcodeproj/project.pbxproj b/OpenTerm.xcodeproj/project.pbxproj index b26cf87f..7e0de9ee 100644 --- a/OpenTerm.xcodeproj/project.pbxproj +++ b/OpenTerm.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ 3CD59E67202FDEBA002298B4 /* TerminalCursorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD59E66202FDEBA002298B4 /* TerminalCursorTests.swift */; }; 3CD59E6920301329002298B4 /* TerminalBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD59E6820301329002298B4 /* TerminalBufferTests.swift */; }; 3CD59E6B20301588002298B4 /* Dispatch+Custom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD59E6A20301588002298B4 /* Dispatch+Custom.swift */; }; + 3CD59E6D20311978002298B4 /* ParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD59E6C20311978002298B4 /* ParserTests.swift */; }; 3CE5764320225E1B00760E43 /* HistoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE5764220225E1B00760E43 /* HistoryManager.swift */; }; 3CE5764820226A1500760E43 /* ANSITextState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE5764720226A1500760E43 /* ANSITextState.swift */; }; 3CE57680202A529200760E43 /* TerminalTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C905FC42025CAEC0084BA63 /* TerminalTabViewController.swift */; }; @@ -133,6 +134,7 @@ 3CD59E66202FDEBA002298B4 /* TerminalCursorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCursorTests.swift; sourceTree = ""; }; 3CD59E6820301329002298B4 /* TerminalBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalBufferTests.swift; sourceTree = ""; }; 3CD59E6A20301588002298B4 /* Dispatch+Custom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dispatch+Custom.swift"; sourceTree = ""; }; + 3CD59E6C20311978002298B4 /* ParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParserTests.swift; sourceTree = ""; }; 3CE5764220225E1B00760E43 /* HistoryManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HistoryManager.swift; sourceTree = ""; }; 3CE5764720226A1500760E43 /* ANSITextState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ANSITextState.swift; sourceTree = ""; }; 3CE5769D202A7EC500760E43 /* Parser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parser.swift; sourceTree = ""; }; @@ -424,6 +426,7 @@ BEC75BFE202B716600216462 /* Info.plist */, 3CD59E66202FDEBA002298B4 /* TerminalCursorTests.swift */, 3CD59E6820301329002298B4 /* TerminalBufferTests.swift */, + 3CD59E6C20311978002298B4 /* ParserTests.swift */, ); path = OpenTermTests; sourceTree = ""; @@ -676,6 +679,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3CD59E6D20311978002298B4 /* ParserTests.swift in Sources */, 3CD59E6920301329002298B4 /* TerminalBufferTests.swift in Sources */, BEC75BFD202B716600216462 /* OpenTermTests.swift in Sources */, 3CD59E67202FDEBA002298B4 /* TerminalCursorTests.swift in Sources */, diff --git a/OpenTerm.xcodeproj/xcshareddata/xcschemes/OpenTerm.xcscheme b/OpenTerm.xcodeproj/xcshareddata/xcschemes/OpenTerm.xcscheme index 32983435..c9fb598b 100644 --- a/OpenTerm.xcodeproj/xcshareddata/xcschemes/OpenTerm.xcscheme +++ b/OpenTerm.xcodeproj/xcshareddata/xcschemes/OpenTerm.xcscheme @@ -27,7 +27,8 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" language = "" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> diff --git a/OpenTerm/Controller/TerminalViewController.swift b/OpenTerm/Controller/TerminalViewController.swift index 09ad072c..a9784413 100644 --- a/OpenTerm/Controller/TerminalViewController.swift +++ b/OpenTerm/Controller/TerminalViewController.swift @@ -296,7 +296,7 @@ class TerminalViewController: UIViewController { return [ // Navigation between commands UIKeyCommand(input: UIKeyInputUpArrow, modifierFlags: UIKeyModifierFlags(rawValue: 0), action: #selector(selectPreviousCommand), discoverabilityTitle: "Previous command"), - UIKeyCommand(input: UIKeyInputDownArrow, modifierFlags: UIKeyModifierFlags(rawValue: 0), action: #selector(selectNextCommand), discoverabilityTitle: "Next command"), + UIKeyCommand(input: UIKeyInputDownArrow, modifierFlags: UIKeyModifierFlags(rawValue: 0), action: #selector(selectNextCommand), discoverabilityTitle: "Next command") ] } diff --git a/OpenTerm/Util/Parsing & Formatting/ANSITextState.swift b/OpenTerm/Util/Parsing & Formatting/ANSITextState.swift index 23dc79ce..88452b87 100644 --- a/OpenTerm/Util/Parsing & Formatting/ANSITextState.swift +++ b/OpenTerm/Util/Parsing & Formatting/ANSITextState.swift @@ -29,10 +29,10 @@ private let colors: [(r: Int, g: Int, b: Int)] = [ (0x67, 0x67, 0xFF), /* Blue */ (0xFF, 0x67, 0xFF), /* Magenta */ (0x67, 0xFF, 0xFF), /* Cyan */ - (0xFF, 0xFF, 0xFF), /* White */ + (0xFF, 0xFF, 0xFF) /* White */ ] -func indexedColor(atIndex index: Int) -> UIColor { +private func indexedColor(atIndex index: Int) -> UIColor { guard index >= 0 && index <= 255 else { fatalError("Index out of bounds.") } let r, g, b: Int if index < 16 { @@ -54,7 +54,7 @@ func indexedColor(atIndex index: Int) -> UIColor { return UIColor(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: 1) } -func customColor(codes: [Int]) -> (color: UIColor, readCount: Int) { +private func customColor(codes: [Int]) -> (color: UIColor, readCount: Int) { // Two supported cases: // - 5;n => 8-bit 0-255 color // - 2;r;g;b => RGB color @@ -82,7 +82,7 @@ func customColor(codes: [Int]) -> (color: UIColor, readCount: Int) { } } -enum ANSIForegroundColor: Int { +private enum ANSIForegroundColor: Int { case `default` = 39 case black = 30 case red = 31 @@ -119,7 +119,7 @@ enum ANSIForegroundColor: Int { } } -enum ANSIBackgroundColor: Int { +private enum ANSIBackgroundColor: Int { case `default` = 49 case black = 40 case red = 41 @@ -156,7 +156,7 @@ enum ANSIBackgroundColor: Int { } } -enum ANSIFontState: Int { +private enum ANSIFontState: Int { case bold = 1 case noBold = 21 @@ -222,18 +222,26 @@ struct ANSITextState { let code = codes[index] var readCount = 1 - // Reset code = reset all state - if code == 0 { reset() } - + if code == 0 { + // Reset code = reset all state + reset() + } else if let foregroundColor = ANSIForegroundColor.init(rawValue: code) { // Foreground color - else if let foregroundColor = ANSIForegroundColor.init(rawValue: code) { self.foregroundColor = foregroundColor.color } - else if code == ANSIForegroundColor.custom { let result = customColor(codes: Array(codes.suffix(from: index + 1))); readCount += result.readCount; foregroundColor = result.color } - + self.foregroundColor = foregroundColor.color + } else if code == ANSIForegroundColor.custom { + // Custom foreground color + let result = customColor(codes: Array(codes.suffix(from: index + 1))) + readCount += result.readCount + foregroundColor = result.color + } else if let backgroundColor = ANSIBackgroundColor.init(rawValue: code) { // Background color - else if let backgroundColor = ANSIBackgroundColor.init(rawValue: code) { self.backgroundColor = backgroundColor.color } - else if code == ANSIBackgroundColor.custom { let result = customColor(codes: Array(codes.suffix(from: index + 1))); readCount += result.readCount; backgroundColor = result.color } - - else if let fontState = ANSIFontState.init(rawValue: code) { + self.backgroundColor = backgroundColor.color + } else if code == ANSIBackgroundColor.custom { + // Custom background color + let result = customColor(codes: Array(codes.suffix(from: index + 1))) + readCount += result.readCount + backgroundColor = result.color + } else if let fontState = ANSIFontState.init(rawValue: code) { switch fontState { case .bold: fontTraits.insert(.traitBold) case .noBold: fontTraits.remove(.traitBold) diff --git a/OpenTermTests/ParserTests.swift b/OpenTermTests/ParserTests.swift new file mode 100644 index 00000000..72af3648 --- /dev/null +++ b/OpenTermTests/ParserTests.swift @@ -0,0 +1,109 @@ +// +// ParserTests.swift +// OpenTermTests +// +// Created by Ian McDowell on 2/11/18. +// Copyright © 2018 Silver Fox. All rights reserved. +// + +import XCTest +@testable import OpenTerm + +class ParserTests: XCTestCase { + + var parser: Parser! + var parserDelegate: TestParserDelegate! + override func setUp() { + super.setUp() + + parser = Parser() + parserDelegate = TestParserDelegate() + parser.delegate = parserDelegate + } + + override func tearDown() { + super.tearDown() + + } + + // Implementation of the ParserDelegate that stores received messages in-order. + class TestParserDelegate: ParserDelegate { + + enum ParserDelegateMessage { + case string(string: NSAttributedString) + case carriageReturn + case newLine + case backspace + case cursorMove(direction: TerminalCursor.Direction, count: Int) + case cursorSet(position: Int, axis: TerminalCursor.Axis) + case endTransmission + } + + var receivedMethods: [ParserDelegateMessage] = [] + + func parser(_ parser: Parser, didReceiveString string: NSAttributedString) { + receivedMethods.append(.string(string: string)) + } + func parserDidReceiveCarriageReturn(_ parser: Parser) { + receivedMethods.append(.carriageReturn) + } + func parserDidReceiveNewLine(_ parser: Parser) { + receivedMethods.append(.newLine) + } + func parserDidReceiveBackspace(_ parser: Parser) { + receivedMethods.append(.backspace) + } + func parser(_ parser: Parser, didMoveCursorInDirection direction: TerminalCursor.Direction, count: Int) { + receivedMethods.append(.cursorMove(direction: direction, count: count)) + } + func parser(_ parser: Parser, didMoveCursorTo position: Int, onAxis axis: TerminalCursor.Axis) { + receivedMethods.append(.cursorSet(position: position, axis: axis)) + } + func parserDidEndTransmission(_ parser: Parser) { + receivedMethods.append(.endTransmission) + } + } + + private func send(_ text: String) { + parser.parse(text.data(using: .utf8)!) + } + + func testBasicText() { + let str = "hello world" + + send(str) + + // Each character should be received in a message + var receivedStr = "" + for method in parserDelegate.receivedMethods { + switch method { + case .string(let str): + receivedStr += str.string + default: + XCTFail("Unexpected method called on parser delegate") + } + } + + XCTAssertEqual(str, receivedStr, "Received string should equal sent string") + } + + func testTextWithNewLine() { + let str = "hello\nworld" + + send(str) + + var receivedStr = "" + for method in parserDelegate.receivedMethods { + switch method { + case .string(let str): + receivedStr += str.string + case .newLine: + receivedStr += "\n" + default: + XCTFail("Unexpected method called on parser delegate") + } + } + + XCTAssertEqual(str, receivedStr, "Received string should equal sent string") + } +} diff --git a/OpenTermTests/TerminalBufferTests.swift b/OpenTermTests/TerminalBufferTests.swift index 9514b8d5..cf3a85cb 100644 --- a/OpenTermTests/TerminalBufferTests.swift +++ b/OpenTermTests/TerminalBufferTests.swift @@ -56,7 +56,7 @@ class TerminalBufferTests: XCTestCase { receiveString(string) - XCTAssert(bufferContents == string, "Buffer should equal the received string") + XCTAssertEqual(bufferContents, string, "Buffer should equal the received string") } func testStringsWithNewLine() { @@ -67,7 +67,7 @@ class TerminalBufferTests: XCTestCase { newLine() receiveString(line2) - XCTAssert(bufferContents == line1 + "\n" + line2, "Buffer should equal the text sent in order") + XCTAssertEqual(bufferContents, line1 + "\n" + line2, "Buffer should equal the text sent in order") } func testStringsWithCarriageReturn() { @@ -79,7 +79,7 @@ class TerminalBufferTests: XCTestCase { carriageReturn() receiveString(line2) - XCTAssert(bufferContents == line2 + line1pt2, "Buffer should equal the second line + leftover first line") + XCTAssertEqual(bufferContents, line2 + line1pt2, "Buffer should equal the second line + leftover first line") } } diff --git a/OpenTermTests/TerminalCursorTests.swift b/OpenTermTests/TerminalCursorTests.swift index 668a8a59..352d8253 100644 --- a/OpenTermTests/TerminalCursorTests.swift +++ b/OpenTermTests/TerminalCursorTests.swift @@ -37,17 +37,17 @@ class TerminalCursorTests: XCTestCase { cursor.move(.right, in: storage) } - XCTAssert(cursor.x == str.count, "Cursor moved the number of times requested") - XCTAssert(cursor.y == 0, "Cursor didn't move vertically") - XCTAssert(cursor.offset == str.count, "Offset == end of string") + XCTAssertEqual(cursor.x, str.count, "Cursor moved the number of times requested") + XCTAssertEqual(cursor.y, 0, "Cursor didn't move vertically") + XCTAssertEqual(cursor.offset, str.count, "Offset == end of string") for _ in 0.. Date: Mon, 12 Feb 2018 17:07:18 -0800 Subject: [PATCH 08/18] Moved ios_system setup to AppDelegate --- OpenTerm/AppDelegate.swift | 15 +++++++++++++++ OpenTerm/Controller/TerminalViewController.swift | 13 ------------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/OpenTerm/AppDelegate.swift b/OpenTerm/AppDelegate.swift index c62d0d16..06bf6832 100644 --- a/OpenTerm/AppDelegate.swift +++ b/OpenTerm/AppDelegate.swift @@ -8,6 +8,7 @@ import UIKit import TabView +import ios_system @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -17,6 +18,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. + initializeEnvironment() + replaceCommand("open-url", mangleFunctionName("openUrl"), true) + replaceCommand("share", mangleFunctionName("shareFile"), true) + replaceCommand("pbcopy", mangleFunctionName("pbcopy"), true) + replaceCommand("pbpaste", mangleFunctionName("pbpaste"), true) + replaceCommand("shell", mangleFunctionName("shell"), true) + window = UIWindow(frame: UIScreen.main.bounds) window?.rootViewController = TabViewContainerViewController(theme: TabViewThemeDark()) window?.tintColor = .defaultMainTintColor @@ -27,6 +35,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } + private func mangleFunctionName(_ functionName: String) -> String { + // This works because all functions have the same signature: + // (argc: Int32, argv: UnsafeMutablePointer?>?) -> Int32 + // The first part is the class name: _T0 + length + name. To change if not "OpenTerm" + return "_T08OpenTerm" + String(functionName.count) + functionName + "s5Int32VAD4argc_SpySpys4Int8VGSgGSg4argvtF" + } + func applicationWillResignActive(_ application: UIApplication) { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. diff --git a/OpenTerm/Controller/TerminalViewController.swift b/OpenTerm/Controller/TerminalViewController.swift index a9784413..6c618ac6 100644 --- a/OpenTerm/Controller/TerminalViewController.swift +++ b/OpenTerm/Controller/TerminalViewController.swift @@ -108,12 +108,6 @@ class TerminalViewController: UIViewController { NotificationCenter.default.addObserver(self, selector: #selector(applicationDidEnterBackground), name: .UIApplicationDidEnterBackground, object: nil) - initializeEnvironment() - replaceCommand("open-url", mangleFunctionName("openUrl"), true) - replaceCommand("share", mangleFunctionName("shareFile"), true) - replaceCommand("pbcopy", mangleFunctionName("pbcopy"), true) - replaceCommand("pbpaste", mangleFunctionName("pbpaste"), true) - // Call reloadData for the added commands. terminalView.autoCompleteManager.reloadData() @@ -167,13 +161,6 @@ class TerminalViewController: UIViewController { self.overflowState = self.traitCollection.horizontalSizeClass == .compact ? .compact : .expanded } - private func mangleFunctionName(_ functionName: String) -> String { - // This works because all functions have the same signature: - // (argc: Int32, argv: UnsafeMutablePointer?>?) -> Int32 - // The first part is the class name: _T0 + length + name. To change if not "OpenTerm" - return "_T08OpenTerm" + String(functionName.count) + functionName + "s5Int32VAD4argc_SpySpys4Int8VGSgGSg4argvtF" - } - func setSSLCertIfNeeded() { guard let cString = getenv("SSL_CERT_FILE") else { From 66ba9f887b4ab5c11a0c8cf43b78756ab1faebcd Mon Sep 17 00:00:00 2001 From: Ian McDowell Date: Mon, 12 Feb 2018 17:08:35 -0800 Subject: [PATCH 09/18] Synchronize TerminalCursor & textview cursor, add distance to TerminalCursor.Direction, fix emoji offset bugs --- .../Util/Parsing & Formatting/Parser.swift | 18 +++--- OpenTerm/Util/Terminal/TerminalBuffer.swift | 48 ++++++++++---- OpenTerm/Util/Terminal/TerminalCursor.swift | 64 +++++++++++-------- OpenTerm/View/TerminalView.swift | 19 ++++-- OpenTermTests/ParserTests.swift | 6 +- OpenTermTests/TerminalCursorTests.swift | 29 ++++----- 6 files changed, 110 insertions(+), 74 deletions(-) diff --git a/OpenTerm/Util/Parsing & Formatting/Parser.swift b/OpenTerm/Util/Parsing & Formatting/Parser.swift index 9a6685ac..323e9541 100644 --- a/OpenTerm/Util/Parsing & Formatting/Parser.swift +++ b/OpenTerm/Util/Parsing & Formatting/Parser.swift @@ -26,7 +26,7 @@ protocol ParserDelegate: class { func parserDidReceiveBackspace(_ parser: Parser) /// The cursor was moved in the given direction, `count` number of times. - func parser(_ parser: Parser, didMoveCursorInDirection direction: TerminalCursor.Direction, count: Int) + func parser(_ parser: Parser, didMoveCursorInDirection direction: TerminalCursor.Direction) /// The cursor was moved to a given position (0.. previousNewLine { - self.x -= 1 - self.offset -= 1 + if distance <= self.x { + self.x -= distance + self.offset -= distance } - case .right: - let nextNewLine = self.indexOfNextNewline(from: offset, in: string) + case .right(let distance): + let nextNewLine = self.indexOfNextNewline(from: offset, in: storedString) // Only move right until we hit a newline - if offset < nextNewLine { - self.x += 1 - self.offset += 1 + if string.index(offset, offsetBy: distance) <= nextNewLine { + self.x += distance + self.offset += distance } case .beginningOfLine: self.x = 0 - self.offset = string.distance(from: string.startIndex, to: self.indexAfterPreviousNewline(from: offset, in: string)) + self.offset = string.distance(from: string.startIndex, to: self.indexAfterPreviousNewline(from: offset, in: storedString)) case .endOfString: - let components = string.components(separatedBy: newlineCharacterSet) + let components = storedString.components(separatedBy: newlineCharacterSet) self.offset = string.count self.y = components.count - 1 self.x = components.last?.count ?? 0 @@ -85,13 +90,13 @@ struct TerminalCursor { mutating func set(_ axis: Axis, to position: Int, in storage: NSTextStorage) { dispatchPrecondition(condition: .onQueue(.main)) - let string = storage.string + let string = storage.string.utf16 let offset = string.index(string.startIndex, offsetBy: self.offset) switch axis { case .x: - let beginningOfLine = self.indexAfterPreviousNewline(from: offset, in: string) - let endOfLine = self.indexOfNextNewline(from: offset, in: string) + let beginningOfLine = self.indexAfterPreviousNewline(from: offset, in: storage.string) + let endOfLine = self.indexOfNextNewline(from: offset, in: storage.string) let lineLength = string.distance(from: beginningOfLine, to: endOfLine) if position <= lineLength { self.x = position @@ -104,9 +109,9 @@ struct TerminalCursor { /// How far is the current x position from the next newline character? func distanceToEndOfLine(in storage: NSTextStorage) -> Int { - let string = storage.string + let string = storage.string.utf16 let offset = string.index(string.startIndex, offsetBy: self.offset) - let index = self.indexOfNextNewline(from: offset, in: string) + let index = self.indexOfNextNewline(from: offset, in: storage.string) let distance = string.distance(from: offset, to: index) return distance } @@ -145,3 +150,10 @@ struct TerminalCursor { return string.rangeOfCharacter(from: newlineCharacterSet, options: [], range: currentPosition.. Date: Mon, 12 Feb 2018 17:45:22 -0800 Subject: [PATCH 10/18] Fixed bug causing history to be corrupted --- OpenTerm/Util/History/HistoryManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/OpenTerm/Util/History/HistoryManager.swift b/OpenTerm/Util/History/HistoryManager.swift index 3ddd680c..5c494f2f 100644 --- a/OpenTerm/Util/History/HistoryManager.swift +++ b/OpenTerm/Util/History/HistoryManager.swift @@ -30,6 +30,7 @@ class HistoryManager { try command.write(to: historyFileURL, atomically: true, encoding: .utf8) } else { let fileHandle = try FileHandle.init(forWritingTo: historyFileURL) + fileHandle.seekToEndOfFile() if let value = (command + "\n").data(using: .utf8) { fileHandle.write(value) } From ba89fcfa83995308740e41d41731f099a6d2c2f8 Mon Sep 17 00:00:00 2001 From: Ian McDowell Date: Mon, 12 Feb 2018 17:58:35 -0800 Subject: [PATCH 11/18] Added Outputsanitizer into the Parser --- OpenTerm/Util/Parsing & Formatting/Parser.swift | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/OpenTerm/Util/Parsing & Formatting/Parser.swift b/OpenTerm/Util/Parsing & Formatting/Parser.swift index 323e9541..ffd1129e 100644 --- a/OpenTerm/Util/Parsing & Formatting/Parser.swift +++ b/OpenTerm/Util/Parsing & Formatting/Parser.swift @@ -108,6 +108,7 @@ class Parser { private var textState: ANSITextState = ANSITextState() private var state: State = .normal private var dataBuffer = Data() + private var pendingString = NSMutableAttributedString() func parse(_ data: Data) { let didEnd = self.decodeUTF8(fromData: data, buffer: &dataBuffer) @@ -120,6 +121,15 @@ class Parser { textState.reset() state = .normal dataBuffer = Data() + pendingString = NSMutableAttributedString() + } + + /// Until a newline, control, or other character is found or the text ends, the text that comes in is appended to the pendingString. + /// This method sanitizes that string, sends it to the delegate, and clears its contents. + private func flushPendingString() { + OutputSanitizer.sanitize(pendingString.mutableString) + self.delegate?.parser(self, didReceiveString: pendingString) + pendingString = NSMutableAttributedString() } private func decodeUTF8(fromData data: Data, buffer: inout Data) -> Bool { @@ -184,11 +194,14 @@ class Parser { switch self.state { case .normal: guard let code = Code.init(rawValue: str) else { - // While in normal mode, unless we found a code, we should return a string using the current + // While in normal mode, unless we found a code, we should generate a string using the current // textState's attributes. - self.delegate?.parser(self, didReceiveString: NSAttributedString.init(string: str, attributes: textState.attributes)) + pendingString.append(NSAttributedString.init(string: str, attributes: textState.attributes)) return false } + // If there is a code, flush whatever plain text we found out to the delegate, since codes can possibly interact with previously outputted strings. + self.flushPendingString() + switch code { case .endOfText, .endOfTransmission: // Ended transmission, return immediately. From d09ebcd1401880d40d0d1f4a00e051bcab29a9c1 Mon Sep 17 00:00:00 2001 From: Ian McDowell Date: Tue, 13 Feb 2018 10:27:53 -0800 Subject: [PATCH 12/18] Fixed parser tests --- OpenTerm/Util/Parsing & Formatting/Parser.swift | 17 ++++++----------- OpenTermTests/ParserTests.swift | 11 +++++++++++ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/OpenTerm/Util/Parsing & Formatting/Parser.swift b/OpenTerm/Util/Parsing & Formatting/Parser.swift index ffd1129e..4555ca72 100644 --- a/OpenTerm/Util/Parsing & Formatting/Parser.swift +++ b/OpenTerm/Util/Parsing & Formatting/Parser.swift @@ -111,10 +111,7 @@ class Parser { private var pendingString = NSMutableAttributedString() func parse(_ data: Data) { - let didEnd = self.decodeUTF8(fromData: data, buffer: &dataBuffer) - if didEnd { - self.delegate?.parserDidEndTransmission(self) - } + self.decodeUTF8(fromData: data, buffer: &dataBuffer) } func reset() { @@ -132,11 +129,11 @@ class Parser { pendingString = NSMutableAttributedString() } - private func decodeUTF8(fromData data: Data, buffer: inout Data) -> Bool { + private func decodeUTF8(fromData data: Data, buffer: inout Data) { let data = buffer + data // Parse what we can from the previous leftover and the new data. - let (leftover, didEnd) = self.decodeUTF8(fromData: data) + let leftover = self.decodeUTF8(fromData: data) // There are two reasons we could get leftover data: // - An invalid character was found in the middle of the string @@ -151,8 +148,6 @@ class Parser { } else { buffer = Data() } - - return didEnd } /// Decode UTF-8 string from the given data. @@ -160,7 +155,7 @@ class Parser { /// which is necessary since data can come in arbitrarily-sized chunks of bytes, with characters split /// across multiple chunks. /// The first time decoding fails, all of the rest of the data will be returned. - private func decodeUTF8(fromData data: Data) -> (remaining: Data, didEnd: Bool) { + private func decodeUTF8(fromData data: Data) -> Data { let byteArray = [UInt8](data) var utf8Decoder = UTF8() @@ -177,8 +172,7 @@ class Parser { } } - let remaining = Data.init(bytes: byteArray.suffix(from: decodedByteCount)) - return (remaining, didEnd) + return Data.init(bytes: byteArray.suffix(from: decodedByteCount)) } /// This method is called for each UTF-8 character that is received. @@ -205,6 +199,7 @@ class Parser { switch code { case .endOfText, .endOfTransmission: // Ended transmission, return immediately. + self.delegate?.parserDidEndTransmission(self) return true case .escape: self.state = .escape diff --git a/OpenTermTests/ParserTests.swift b/OpenTermTests/ParserTests.swift index 996f9207..3947bbf8 100644 --- a/OpenTermTests/ParserTests.swift +++ b/OpenTermTests/ParserTests.swift @@ -68,10 +68,16 @@ class ParserTests: XCTestCase { parser.parse(text.data(using: .utf8)!) } + private func end() { + // Must send end of transmission when we are done, since that will flush the pending text out of the parser + parser.parse(Parser.Code.endOfTransmission.rawValue.data(using: .utf8)!) + } + func testBasicText() { let str = "hello world" send(str) + end() // Each character should be received in a message var receivedStr = "" @@ -79,6 +85,8 @@ class ParserTests: XCTestCase { switch method { case .string(let str): receivedStr += str.string + case .endTransmission: + break default: XCTFail("Unexpected method called on parser delegate") } @@ -91,6 +99,7 @@ class ParserTests: XCTestCase { let str = "hello\nworld" send(str) + end() var receivedStr = "" for method in parserDelegate.receivedMethods { @@ -99,6 +108,8 @@ class ParserTests: XCTestCase { receivedStr += str.string case .newLine: receivedStr += "\n" + case .endTransmission: + break default: XCTFail("Unexpected method called on parser delegate") } From 59f516d36806f3379692f51bc33fa6877b40a5d9 Mon Sep 17 00:00:00 2001 From: Ian McDowell Date: Tue, 13 Feb 2018 10:30:51 -0800 Subject: [PATCH 13/18] Added basic sanitizer test, refactored ParserTests a bit --- OpenTermTests/ParserTests.swift | 45 +++++++++++++++++---------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/OpenTermTests/ParserTests.swift b/OpenTermTests/ParserTests.swift index 3947bbf8..832b9810 100644 --- a/OpenTermTests/ParserTests.swift +++ b/OpenTermTests/ParserTests.swift @@ -73,26 +73,32 @@ class ParserTests: XCTestCase { parser.parse(Parser.Code.endOfTransmission.rawValue.data(using: .utf8)!) } - func testBasicText() { - let str = "hello world" - - send(str) - end() - - // Each character should be received in a message + private var receivedString: String { var receivedStr = "" for method in parserDelegate.receivedMethods { switch method { case .string(let str): receivedStr += str.string + case .newLine: + receivedStr += "\n" + case .carriageReturn: + receivedStr += "\r" case .endTransmission: break default: XCTFail("Unexpected method called on parser delegate") } } + return receivedStr + } - XCTAssertEqual(str, receivedStr, "Received string should equal sent string") + func testBasicText() { + let str = "hello world" + + send(str) + end() + + XCTAssertEqual(str, receivedString, "Received string should equal sent string") } func testTextWithNewLine() { @@ -101,20 +107,15 @@ class ParserTests: XCTestCase { send(str) end() - var receivedStr = "" - for method in parserDelegate.receivedMethods { - switch method { - case .string(let str): - receivedStr += str.string - case .newLine: - receivedStr += "\n" - case .endTransmission: - break - default: - XCTFail("Unexpected method called on parser delegate") - } - } + XCTAssertEqual(str, receivedString, "Received string should equal sent string") + } + + func testSanitizedOutput() { + let str = DocumentManager.shared.activeDocumentsFolderURL.path + + send(str) + end() - XCTAssertEqual(str, receivedStr, "Received string should equal sent string") + XCTAssertEqual(receivedString, "~") } } From 872573a06ef6d0e372a317588492d6d27e04fcb8 Mon Sep 17 00:00:00 2001 From: Ian McDowell Date: Tue, 13 Feb 2018 11:24:34 -0800 Subject: [PATCH 14/18] Fix for missing libtext.dylib & share reading from stdin (#90) * Added libtext.dylib to OpenTerm * Fixed share stdin --- OpenTerm.xcodeproj/project.pbxproj | 4 ++++ OpenTerm/Commands/Share.swift | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/OpenTerm.xcodeproj/project.pbxproj b/OpenTerm.xcodeproj/project.pbxproj index 873d76ef..c23eddea 100644 --- a/OpenTerm.xcodeproj/project.pbxproj +++ b/OpenTerm.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 3C1A47B02031357500D7CC5C /* ios_system.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BE3768EE1FEC4DCE00D5A2D1 /* ios_system.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3C1A47B12031357500D7CC5C /* Pods_OpenTerm.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FC01604DAC695ABD64544260 /* Pods_OpenTerm.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3C1A47B22031357B00D7CC5C /* ios_system.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE3768EE1FEC4DCE00D5A2D1 /* ios_system.framework */; }; + 3C1A47B520336F0F00D7CC5C /* libtext.dylib in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3C1A47B320336F0200D7CC5C /* libtext.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 3C2E4374201EF67C00E4254A /* TerminalView+AutoComplete.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2E4373201EF67C00E4254A /* TerminalView+AutoComplete.swift */; }; 3C2E4385201EFF4700E4254A /* AutoCompleteManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2E4384201EFF4700E4254A /* AutoCompleteManager.swift */; }; 3C406E1A20207CE7005F97C4 /* CommandExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C406E1920207CE7005F97C4 /* CommandExecutor.swift */; }; @@ -88,6 +89,7 @@ 3C1A47AD2031357500D7CC5C /* libshell.dylib in Embed Frameworks */, 3C1A47AE2031357500D7CC5C /* libssh_cmd.dylib in Embed Frameworks */, 3C1A47AF2031357500D7CC5C /* libtar.dylib in Embed Frameworks */, + 3C1A47B520336F0F00D7CC5C /* libtext.dylib in Embed Frameworks */, 3C1A47B02031357500D7CC5C /* ios_system.framework in Embed Frameworks */, 3C1A47B12031357500D7CC5C /* Pods_OpenTerm.framework in Embed Frameworks */, ); @@ -106,6 +108,7 @@ 3C1A47A12031355700D7CC5C /* libshell.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; path = libshell.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; 3C1A47A22031355700D7CC5C /* libssh_cmd.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; path = libssh_cmd.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; 3C1A47A32031355700D7CC5C /* libtar.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; path = libtar.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; + 3C1A47B320336F0200D7CC5C /* libtext.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; path = libtext.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; 3C2E4373201EF67C00E4254A /* TerminalView+AutoComplete.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalView+AutoComplete.swift"; sourceTree = ""; }; 3C2E4384201EFF4700E4254A /* AutoCompleteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteManager.swift; sourceTree = ""; }; 3C406E1920207CE7005F97C4 /* CommandExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandExecutor.swift; sourceTree = ""; }; @@ -298,6 +301,7 @@ BE3768EC1FEC4DCE00D5A2D1 /* Frameworks */ = { isa = PBXGroup; children = ( + 3C1A47B320336F0200D7CC5C /* libtext.dylib */, 3C1A479F2031355700D7CC5C /* libawk.dylib */, 3C1A479E2031355700D7CC5C /* libcurl.dylib */, 3C1A47A02031355700D7CC5C /* libfiles.dylib */, diff --git a/OpenTerm/Commands/Share.swift b/OpenTerm/Commands/Share.swift index 94385de4..c9e7348c 100644 --- a/OpenTerm/Commands/Share.swift +++ b/OpenTerm/Commands/Share.swift @@ -16,7 +16,7 @@ public func shareFile(argc: Int32, argv: UnsafeMutablePointer Date: Tue, 13 Feb 2018 11:26:28 -0800 Subject: [PATCH 15/18] Added ls colors test --- OpenTermTests/ParserTests.swift | 74 +++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/OpenTermTests/ParserTests.swift b/OpenTermTests/ParserTests.swift index 832b9810..7b722d18 100644 --- a/OpenTermTests/ParserTests.swift +++ b/OpenTermTests/ParserTests.swift @@ -73,16 +73,20 @@ class ParserTests: XCTestCase { parser.parse(Parser.Code.endOfTransmission.rawValue.data(using: .utf8)!) } - private var receivedString: String { - var receivedStr = "" + private func receivedString(withControlCharacters controlCharacters: Bool = true) -> NSAttributedString { + let receivedStr = NSMutableAttributedString() for method in parserDelegate.receivedMethods { switch method { case .string(let str): - receivedStr += str.string + receivedStr.append(str) case .newLine: - receivedStr += "\n" + if controlCharacters { + receivedStr.append(NSAttributedString.init(string: "\n")) + } case .carriageReturn: - receivedStr += "\r" + if controlCharacters { + receivedStr.append(NSAttributedString.init(string: "\r")) + } case .endTransmission: break default: @@ -98,7 +102,7 @@ class ParserTests: XCTestCase { send(str) end() - XCTAssertEqual(str, receivedString, "Received string should equal sent string") + XCTAssertEqual(str, receivedString().string, "Received string should equal sent string") } func testTextWithNewLine() { @@ -107,7 +111,7 @@ class ParserTests: XCTestCase { send(str) end() - XCTAssertEqual(str, receivedString, "Received string should equal sent string") + XCTAssertEqual(str, receivedString().string, "Received string should equal sent string") } func testSanitizedOutput() { @@ -116,6 +120,60 @@ class ParserTests: XCTestCase { send(str) end() - XCTAssertEqual(receivedString, "~") + XCTAssertEqual(receivedString().string, "~") } + + func testLSColors() { + let esc = Parser.Code.escape.rawValue + // First line = normal output + let line1 = "cacert.pem\tctd.cpp\techoTest\tinput\tknown_hosts" + // Second line = bold / blue "lua" + let line2text = "lua" + let line2 = "\(esc)[1m\(esc)[34m\(line2text)\(esc)[39;49m\(esc)[0m" + // Third line = normal "path" + let line3 = "path" + // Fourth line = bold / green "test" + let line4text = "test" + let line4 = "\(esc)[1m\(esc)[32m\(line4text)\(esc)[39;49m\(esc)[0m" + // Fifth line = normal "test.tar.gz" + let line5 = "test.tar.gz" + // Sixth line = bold / purple "test2" + let line6text = "test2" + let line6 = "\(esc)[1m\(esc)[35m\(line6text)\(esc)[39;49m\(esc)[0m" + + send([line1, line2, line3, line4, line5, line6].joined(separator: "\n")) + end() + + let received = self.receivedString(withControlCharacters: false) + + // Retrieve an attributed substring for each line + var currentPosition = 0 + let rLine1 = received.attributedSubstring(from: NSRange.init(location: currentPosition, length: line1.count)) + currentPosition += rLine1.length + let rLine2 = received.attributedSubstring(from: NSRange.init(location: currentPosition, length: line2text.count)) + currentPosition += rLine2.length + let rLine3 = received.attributedSubstring(from: NSRange.init(location: currentPosition, length: line3.count)) + currentPosition += rLine3.length + let rLine4 = received.attributedSubstring(from: NSRange.init(location: currentPosition, length: line4text.count)) + currentPosition += rLine4.length + let rLine5 = received.attributedSubstring(from: NSRange.init(location: currentPosition, length: line5.count)) + currentPosition += rLine5.length + let rLine6 = received.attributedSubstring(from: NSRange.init(location: currentPosition, length: line6text.count)) + currentPosition += rLine6.length + + // Make sure we got through the whole string + XCTAssertEqual(currentPosition, received.length) + + // Make sure lines are equal to what we passed in + XCTAssertEqual(rLine1.string, line1) + XCTAssertEqual(rLine2.string, line2text) + XCTAssertEqual(rLine3.string, line3) + XCTAssertEqual(rLine4.string, line4text) + XCTAssertEqual(rLine5.string, line5) + XCTAssertEqual(rLine6.string, line6text) + + // For lines with styles, make sure styles were applied + // TODO + } + } From 3d36b8cebc7021ed8128febe37aca2665a490957 Mon Sep 17 00:00:00 2001 From: Ian McDowell Date: Tue, 13 Feb 2018 23:01:18 -0800 Subject: [PATCH 16/18] Added CommandExecutorTests, ls & cat tests --- OpenTerm.xcodeproj/project.pbxproj | 4 + OpenTerm/Util/Execution/CommandExecutor.swift | 13 +- .../Util/Parsing & Formatting/Parser.swift | 9 + OpenTerm/Util/Terminal/TerminalBuffer.swift | 8 +- OpenTermTests/CommandExecutorTests.swift | 172 ++++++++++++++++++ OpenTermTests/ParserTests.swift | 2 +- OpenTermTests/TerminalBufferTests.swift | 2 +- 7 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 OpenTermTests/CommandExecutorTests.swift diff --git a/OpenTerm.xcodeproj/project.pbxproj b/OpenTerm.xcodeproj/project.pbxproj index c23eddea..7db4ffd9 100644 --- a/OpenTerm.xcodeproj/project.pbxproj +++ b/OpenTerm.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 3C1A47B12031357500D7CC5C /* Pods_OpenTerm.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FC01604DAC695ABD64544260 /* Pods_OpenTerm.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3C1A47B22031357B00D7CC5C /* ios_system.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE3768EE1FEC4DCE00D5A2D1 /* ios_system.framework */; }; 3C1A47B520336F0F00D7CC5C /* libtext.dylib in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3C1A47B320336F0200D7CC5C /* libtext.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + 3C1A47B72033F0E600D7CC5C /* CommandExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C1A47B62033F0E600D7CC5C /* CommandExecutorTests.swift */; }; 3C2E4374201EF67C00E4254A /* TerminalView+AutoComplete.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2E4373201EF67C00E4254A /* TerminalView+AutoComplete.swift */; }; 3C2E4385201EFF4700E4254A /* AutoCompleteManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2E4384201EFF4700E4254A /* AutoCompleteManager.swift */; }; 3C406E1A20207CE7005F97C4 /* CommandExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C406E1920207CE7005F97C4 /* CommandExecutor.swift */; }; @@ -109,6 +110,7 @@ 3C1A47A22031355700D7CC5C /* libssh_cmd.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; path = libssh_cmd.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; 3C1A47A32031355700D7CC5C /* libtar.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; path = libtar.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; 3C1A47B320336F0200D7CC5C /* libtext.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; path = libtext.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; + 3C1A47B62033F0E600D7CC5C /* CommandExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandExecutorTests.swift; sourceTree = ""; }; 3C2E4373201EF67C00E4254A /* TerminalView+AutoComplete.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalView+AutoComplete.swift"; sourceTree = ""; }; 3C2E4384201EFF4700E4254A /* AutoCompleteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteManager.swift; sourceTree = ""; }; 3C406E1920207CE7005F97C4 /* CommandExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandExecutor.swift; sourceTree = ""; }; @@ -407,6 +409,7 @@ 3CD59E66202FDEBA002298B4 /* TerminalCursorTests.swift */, 3CD59E6820301329002298B4 /* TerminalBufferTests.swift */, 3CD59E6C20311978002298B4 /* ParserTests.swift */, + 3C1A47B62033F0E600D7CC5C /* CommandExecutorTests.swift */, ); path = OpenTermTests; sourceTree = ""; @@ -662,6 +665,7 @@ 3CD59E6920301329002298B4 /* TerminalBufferTests.swift in Sources */, BEC75BFD202B716600216462 /* OpenTermTests.swift in Sources */, 3CD59E67202FDEBA002298B4 /* TerminalCursorTests.swift in Sources */, + 3C1A47B72033F0E600D7CC5C /* CommandExecutorTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/OpenTerm/Util/Execution/CommandExecutor.swift b/OpenTerm/Util/Execution/CommandExecutor.swift index 1d4dbb0a..a7b1057d 100644 --- a/OpenTerm/Util/Execution/CommandExecutor.swift +++ b/OpenTerm/Util/Execution/CommandExecutor.swift @@ -59,12 +59,14 @@ class CommandExecutor { private let stdin_pipe = Pipe() private let stdout_pipe = Pipe() private let stderr_pipe = Pipe() - fileprivate let stdin_file: UnsafeMutablePointer + + // Files for pipes, passed to ios_system + private let stdin_file: UnsafeMutablePointer private let stdout_file: UnsafeMutablePointer private let stderr_file: UnsafeMutablePointer /// Context from commands run by this executor - private var context = CommandExecutionContext() + var context = CommandExecutionContext() init() { self.currentWorkingDirectory = DocumentManager.shared.activeDocumentsFolderURL @@ -117,9 +119,10 @@ class CommandExecutor { // Save return code into the context self.context[.status] = "\(returnCode)" - // Write the end code to stdout_pipe - // TODO: Also need to send to stderr? - self.stdout_pipe.fileHandleForWriting.write(Parser.Code.endOfTransmission.rawValue.data(using: .utf8)!) + // Write the end code to stdout and stderr + let etx = Parser.Code.endOfTransmission.rawValue.data(using: .utf8)! + self.stdout_pipe.fileHandleForWriting.write(etx) + self.stderr_pipe.fileHandleForWriting.write(etx) stdin = push_stdin stdout = push_stdout diff --git a/OpenTerm/Util/Parsing & Formatting/Parser.swift b/OpenTerm/Util/Parsing & Formatting/Parser.swift index 4555ca72..b4a54c4b 100644 --- a/OpenTerm/Util/Parsing & Formatting/Parser.swift +++ b/OpenTerm/Util/Parsing & Formatting/Parser.swift @@ -39,6 +39,10 @@ protocol ParserDelegate: class { /// It reads the data by character and performs actions based on control codes, including applying colors. /// For more information about escape codes, see https://en.wikipedia.org/wiki/ANSI_escape_code class Parser { + enum ParserType { + case stdout, stderr, stdin + } + /// List of constants that are needed for parsing. enum Code: String { case escape = "\u{1B}" @@ -105,11 +109,16 @@ class Parser { } weak var delegate: ParserDelegate? + let type: ParserType private var textState: ANSITextState = ANSITextState() private var state: State = .normal private var dataBuffer = Data() private var pendingString = NSMutableAttributedString() + init(type: ParserType) { + self.type = type + } + func parse(_ data: Data) { self.decodeUTF8(fromData: data, buffer: &dataBuffer) } diff --git a/OpenTerm/Util/Terminal/TerminalBuffer.swift b/OpenTerm/Util/Terminal/TerminalBuffer.swift index 0c4f5f2c..49514a12 100644 --- a/OpenTerm/Util/Terminal/TerminalBuffer.swift +++ b/OpenTerm/Util/Terminal/TerminalBuffer.swift @@ -51,9 +51,9 @@ class TerminalBuffer { layoutManager = NSLayoutManager() textContainer = NSTextContainer() - stdoutParser = Parser() - stderrParser = Parser() - stdinParser = Parser() + stdoutParser = Parser(type: .stdout) + stderrParser = Parser(type: .stderr) + stdinParser = Parser(type: .stdin) cursor = .zero @@ -158,6 +158,8 @@ extension TerminalBuffer: ParserDelegate { } } func parserDidEndTransmission(_ parser: Parser) { + // TODO: Only send ETX delegate method when both stdout and stderr parsers end transmission. + if parser.type != .stdout { return } DispatchQueue.performOnMain { self.delegate?.terminalBufferDidReceiveETX() } diff --git a/OpenTermTests/CommandExecutorTests.swift b/OpenTermTests/CommandExecutorTests.swift new file mode 100644 index 00000000..51ea903e --- /dev/null +++ b/OpenTermTests/CommandExecutorTests.swift @@ -0,0 +1,172 @@ +// +// CommandExecutorTests.swift +// OpenTermTests +// +// Created by Ian McDowell on 2/13/18. +// Copyright © 2018 Silver Fox. All rights reserved. +// + +import XCTest +@testable import OpenTerm + +class CommandExecutorTests: XCTestCase { + + private let workingDirectory = DocumentManager.shared.activeDocumentsFolderURL.appendingPathComponent("UnitTest") + private let testFileNames = ["test.txt"] + private let testFolderNames = ["Folder"] + var executor: CommandExecutor! + + override func setUp() { + super.setUp() + + executor = CommandExecutor() + + // Create a working directory + try! FileManager.default.createDirectory(at: workingDirectory, withIntermediateDirectories: true, attributes: nil) + executor.currentWorkingDirectory = workingDirectory + + // Put some test files in the directory + for file in testFileNames { + // Write the file name to a file by the same name + try! file.write(to: workingDirectory.appendingPathComponent(file), atomically: true, encoding: .utf8) + } + + // Put some test folders in the directory + for folder in testFolderNames { + try! FileManager.default.createDirectory(at: workingDirectory.appendingPathComponent(folder), withIntermediateDirectories: true, attributes: nil) + } + } + + override func tearDown() { + super.tearDown() + + if FileManager.default.fileExists(atPath: workingDirectory.path) { + try! FileManager.default.removeItem(at: workingDirectory) + } + } + + func testLS() { + let (returnCode, stdout, stderr) = executor.run("ls") + + XCTAssertEqual(returnCode, 0) + XCTAssertEqual(stderr.count, 0) + + guard let stdoutStr = String.init(data: stdout, encoding: .utf8) else { + XCTFail("Unable to decode stdout") + return + } + + for name in testFileNames + testFolderNames { + XCTAssert(stdoutStr.contains(name)) + } + } + + func testCat() { + for file in testFileNames { + let (returnCode, stdout, stderr) = executor.run("cat \(file)") + + XCTAssertEqual(returnCode, 0) + XCTAssertEqual(stderr.count, 0) + + guard let stdoutStr = String.init(data: stdout, encoding: .utf8) else { + XCTFail("Unable to decode stdout") + return + } + + // Since file contains its name, the output should equal the file name + XCTAssertEqual(stdoutStr, file) + } + } +} + + +extension CommandExecutor { + + func run(_ command: String) -> (returnCode: Int32, stdout: Data, stderr: Data) { + + var rc: Int32 = 0 + var out = Data() + var err = Data() + + let sem = DispatchSemaphore.init(value: 0) + + let delegate = RunDelegate { returnCode, stdout, stderr in + rc = returnCode + out = stdout + err = stderr + + sem.signal() + } + + self.delegate = delegate + self.dispatch(command) + + sem.wait() + + return (rc, out, err) + } + + // CommandExecutorDelegate that calls back when process exits and outputs are closed + private class RunDelegate: CommandExecutorDelegate { + + typealias ExecutorCallback = (_ returnCode: Int32, _ stdout: Data, _ stderr: Data) -> Void + + var callback: ExecutorCallback + + private var stdout = Data() + private var stdoutReceivedEnd = false { + didSet { callbackIfComplete() } + } + + private var stderr = Data() + private var stderrReceivedEnd = false { + didSet { callbackIfComplete() } + } + + private var returnCode: Int32 = 0 + private var hasCompleted = false { + didSet { callbackIfComplete() } + } + + init(_ callback: @escaping ExecutorCallback) { + self.callback = callback + } + + private let endOfTransmission = Parser.Code.endOfTransmission.rawValue.data(using: .utf8)!.first! + + private func callbackIfComplete() { + if stdoutReceivedEnd && stderrReceivedEnd && hasCompleted { + callback(self.returnCode, self.stdout, self.stderr) + } + } + + func commandExecutor(_ commandExecutor: CommandExecutor, receivedStdout stdout: Data) { + self.stdout += stdout + + if stdout.last == endOfTransmission { + self.stdout.removeLast() + stdoutReceivedEnd = true + } + } + func commandExecutor(_ commandExecutor: CommandExecutor, receivedStderr stderr: Data) { + self.stderr += stderr + + if stderr.last == endOfTransmission { + self.stderr.removeLast() + stderrReceivedEnd = true + } + } + func commandExecutor(_ commandExecutor: CommandExecutor, stateDidChange newState: CommandExecutor.State) { + switch newState { + case .idle: + self.returnCode = Int32(commandExecutor.context[.status]!) ?? 0 + self.hasCompleted = true + default: + break + } + } + func commandExecutor(_ commandExecutor: CommandExecutor, didChangeWorkingDirectory to: URL) { + + } + } +} diff --git a/OpenTermTests/ParserTests.swift b/OpenTermTests/ParserTests.swift index 7b722d18..2db7e15d 100644 --- a/OpenTermTests/ParserTests.swift +++ b/OpenTermTests/ParserTests.swift @@ -16,7 +16,7 @@ class ParserTests: XCTestCase { override func setUp() { super.setUp() - parser = Parser() + parser = Parser(type: .stdout) parserDelegate = TestParserDelegate() parser.delegate = parserDelegate } diff --git a/OpenTermTests/TerminalBufferTests.swift b/OpenTermTests/TerminalBufferTests.swift index cf3a85cb..34944b6d 100644 --- a/OpenTermTests/TerminalBufferTests.swift +++ b/OpenTermTests/TerminalBufferTests.swift @@ -18,7 +18,7 @@ class TerminalBufferTests: XCTestCase { // Passing delegate methods from a parser require a Parser parameter. Pass an empty one in to get it to compile. // If the TerminalBuffer ever reads stuff about the parser (it currently does not), this will need a better implementation // Such as to make the TerminalBuffer's parser non-private - let dummyParser = Parser() + let dummyParser = Parser(type: .stdout) override func setUp() { super.setUp() From f4744407ebbe209bc1d0adb872254bff26232697 Mon Sep 17 00:00:00 2001 From: Ian McDowell Date: Tue, 13 Feb 2018 23:13:26 -0800 Subject: [PATCH 17/18] Performance improvements for ANSITextState --- OpenTerm/AppDelegate.swift | 1 - OpenTerm/Util/Parsing & Formatting/ANSITextState.swift | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/OpenTerm/AppDelegate.swift b/OpenTerm/AppDelegate.swift index 06bf6832..77af9101 100644 --- a/OpenTerm/AppDelegate.swift +++ b/OpenTerm/AppDelegate.swift @@ -23,7 +23,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { replaceCommand("share", mangleFunctionName("shareFile"), true) replaceCommand("pbcopy", mangleFunctionName("pbcopy"), true) replaceCommand("pbpaste", mangleFunctionName("pbpaste"), true) - replaceCommand("shell", mangleFunctionName("shell"), true) window = UIWindow(frame: UIScreen.main.bounds) window?.rootViewController = TabViewContainerViewController(theme: TabViewThemeDark()) diff --git a/OpenTerm/Util/Parsing & Formatting/ANSITextState.swift b/OpenTerm/Util/Parsing & Formatting/ANSITextState.swift index 88452b87..a67c7d7b 100644 --- a/OpenTerm/Util/Parsing & Formatting/ANSITextState.swift +++ b/OpenTerm/Util/Parsing & Formatting/ANSITextState.swift @@ -181,7 +181,9 @@ struct ANSITextState { var font: UIFont = ANSITextState.font(fromTraits: []) var fontTraits: UIFontDescriptorSymbolicTraits = [] { didSet { - self.font = ANSITextState.font(fromTraits: fontTraits) + if fontTraits != oldValue { + self.font = ANSITextState.font(fromTraits: fontTraits) + } } } From c577698eca606a32c7d95373847099c16c0312c5 Mon Sep 17 00:00:00 2001 From: Ian McDowell Date: Wed, 14 Feb 2018 19:35:12 -0800 Subject: [PATCH 18/18] No longer mess with process stdout/stderr since it causes bugs --- OpenTerm/Util/Execution/CommandExecutor.swift | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/OpenTerm/Util/Execution/CommandExecutor.swift b/OpenTerm/Util/Execution/CommandExecutor.swift index a7b1057d..4eff9d4c 100644 --- a/OpenTerm/Util/Execution/CommandExecutor.swift +++ b/OpenTerm/Util/Execution/CommandExecutor.swift @@ -61,9 +61,9 @@ class CommandExecutor { private let stderr_pipe = Pipe() // Files for pipes, passed to ios_system - private let stdin_file: UnsafeMutablePointer - private let stdout_file: UnsafeMutablePointer - private let stderr_file: UnsafeMutablePointer + fileprivate let stdin_file: UnsafeMutablePointer + fileprivate let stdout_file: UnsafeMutablePointer + fileprivate let stderr_file: UnsafeMutablePointer /// Context from commands run by this executor var context = CommandExecutionContext() @@ -84,18 +84,13 @@ class CommandExecutor { // Dispatch a new text-based command to execute. func dispatch(_ command: String) { - let push_stdin = stdin - let push_stdout = stdout - let push_stderr = stderr CommandExecutor.executionQueue.async { self.state = .running // Set the executor's CWD as the process-wide CWD DocumentManager.shared.currentDirectoryURL = self.currentWorkingDirectory - stdin = self.stdin_file - stdout = self.stdout_file - stderr = self.stderr_file + let returnCode: ReturnCode do { let executorCommand = self.executorCommand(forCommand: command, inContext: self.context) @@ -124,10 +119,6 @@ class CommandExecutor { self.stdout_pipe.fileHandleForWriting.write(etx) self.stderr_pipe.fileHandleForWriting.write(etx) - stdin = push_stdin - stdout = push_stdout - stderr = push_stderr - self.state = .idle } } @@ -190,14 +181,12 @@ struct SystemExecutorCommand: CommandExecutorCommand { func run(forExecutor executor: CommandExecutor) throws -> ReturnCode { - // Pass the value of the string to system, return its exit code. - let returnCode = ios_system(command.utf8CString) + thread_stdin = executor.stdin_file + thread_stdout = executor.stdout_file + thread_stderr = executor.stderr_file - // Flush pipes to make sure all data is read - fflush(stdout) - fflush(stderr) - - return returnCode + // Pass the value of the string to system, return its exit code. + return ios_system(command.utf8CString) } }