diff --git a/OpenTerm.xcodeproj/project.pbxproj b/OpenTerm.xcodeproj/project.pbxproj index 2bd9c247..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 */; }; @@ -31,12 +32,18 @@ 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 */; }; + 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 */; }; 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 */; }; BE165408201909040067EC92 /* xCallBackUrl.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE165407201909030067EC92 /* xCallBackUrl.swift */; }; BE244DC8201FBB6000A7EA4E /* cacert.pem in Resources */ = {isa = PBXBuildFile; fileRef = BE244DC7201FBB6000A7EA4E /* cacert.pem */; }; @@ -103,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 = ""; }; @@ -115,10 +123,16 @@ 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; 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 = ""; }; + 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 = ""; }; 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 = ""; }; BE165407201909030067EC92 /* xCallBackUrl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = xCallBackUrl.swift; sourceTree = ""; }; BE244DC7201FBB6000A7EA4E /* cacert.pem */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = cacert.pem; sourceTree = ""; }; @@ -243,6 +257,15 @@ path = Scripting; sourceTree = ""; }; + 3CD59E61202EFDFA002298B4 /* Terminal */ = { + isa = PBXGroup; + children = ( + 3CD59E62202EFE17002298B4 /* TerminalCursor.swift */, + 3CE576A9202E61DE00760E43 /* TerminalBuffer.swift */, + ); + path = Terminal; + sourceTree = ""; + }; 3CE5764120225E1B00760E43 /* History */ = { isa = PBXGroup; children = ( @@ -360,6 +383,7 @@ BEA8E28F2001346D00002475 /* Util */ = { isa = PBXGroup; children = ( + 3CD59E61202EFDFA002298B4 /* Terminal */, 3CE576442022607000760E43 /* Parsing & Formatting */, 3C406E1B2020987B005F97C4 /* AutoComplete */, 3C406E1820207CDA005F97C4 /* Execution */, @@ -372,6 +396,7 @@ BEA499251FD9C4D7001B9B9D /* DocumentManager.swift */, BE9275052013961D00BD2761 /* UserDefaultsController.swift */, 3C905FC920265BC60084BA63 /* StoreReviewPrompter.swift */, + 3CD59E6A20301588002298B4 /* Dispatch+Custom.swift */, ); path = Util; sourceTree = ""; @@ -381,6 +406,10 @@ children = ( BEC75BFC202B716600216462 /* OpenTermTests.swift */, BEC75BFE202B716600216462 /* Info.plist */, + 3CD59E66202FDEBA002298B4 /* TerminalCursorTests.swift */, + 3CD59E6820301329002298B4 /* TerminalBufferTests.swift */, + 3CD59E6C20311978002298B4 /* ParserTests.swift */, + 3C1A47B62033F0E600D7CC5C /* CommandExecutorTests.swift */, ); path = OpenTermTests; sourceTree = ""; @@ -600,10 +629,13 @@ 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 */, + 3CD59E6B20301588002298B4 /* Dispatch+Custom.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 */, @@ -629,7 +661,11 @@ 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 */, + 3C1A47B72033F0E600D7CC5C /* CommandExecutorTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 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/AppDelegate.swift b/OpenTerm/AppDelegate.swift index c62d0d16..77af9101 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,12 @@ 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) + window = UIWindow(frame: UIScreen.main.bounds) window?.rootViewController = TabViewContainerViewController(theme: TabViewThemeDark()) window?.tintColor = .defaultMainTintColor @@ -27,6 +34,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 09ad072c..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 { @@ -296,7 +283,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/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/Execution/CommandExecutor.swift b/OpenTerm/Util/Execution/CommandExecutor.swift index 1d4dbb0a..4eff9d4c 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() + + // Files for pipes, passed to ios_system fileprivate let stdin_file: UnsafeMutablePointer - private let stdout_file: UnsafeMutablePointer - private let stderr_file: UnsafeMutablePointer + fileprivate let stdout_file: UnsafeMutablePointer + fileprivate let stderr_file: UnsafeMutablePointer /// Context from commands run by this executor - private var context = CommandExecutionContext() + var context = CommandExecutionContext() init() { self.currentWorkingDirectory = DocumentManager.shared.activeDocumentsFolderURL @@ -82,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) @@ -117,13 +114,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)!) - - stdin = push_stdin - stdout = push_stdout - stderr = push_stderr + // 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) self.state = .idle } @@ -187,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) - - // Flush pipes to make sure all data is read - fflush(stdout) - fflush(stderr) + thread_stdin = executor.stdin_file + thread_stdout = executor.stdout_file + thread_stderr = executor.stderr_file - return returnCode + // Pass the value of the string to system, return its exit code. + return ios_system(command.utf8CString) } } 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) } diff --git a/OpenTerm/Util/Parsing & Formatting/ANSITextState.swift b/OpenTerm/Util/Parsing & Formatting/ANSITextState.swift index e62e13d8..a67c7d7b 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 @@ -175,13 +175,15 @@ 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: []) var fontTraits: UIFontDescriptorSymbolicTraits = [] { didSet { - self.font = ANSITextState.font(fromTraits: fontTraits) + if fontTraits != oldValue { + self.font = ANSITextState.font(fromTraits: fontTraits) + } } } @@ -222,18 +224,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/OpenTerm/Util/Parsing & Formatting/Parser.swift b/OpenTerm/Util/Parsing & Formatting/Parser.swift index d3b23f47..b4a54c4b 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) + + /// The cursor was moved to a given position (0.. (decoded: NSAttributedString, didEnd: 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 (str, 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 @@ -115,8 +157,6 @@ class Parser { } else { buffer = Data() } - - return (str, didEnd) } /// Decode UTF-8 string from the given data. @@ -124,36 +164,32 @@ 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) -> Data { 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 } } - let remaining = Data.init(bytes: byteArray.suffix(from: decodedByteCount)) - return (str, remaining, didEnd) + return Data.init(bytes: byteArray.suffix(from: decodedByteCount)) } /// 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) { + // + // swiftlint:disable cyclomatic_complexity + private func handle(_ character: Character) -> Bool { // Create a string with the given character let str = String.init(character) @@ -161,17 +197,33 @@ 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. - return (NSAttributedString.init(string: str, attributes: textState.attributes), false) + 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 .endOfTransmission: + case .endOfText, .endOfTransmission: // Ended transmission, return immediately. - return (nil, true) + self.delegate?.parserDidEndTransmission(self) + return true case .escape: self.state = .escape - default: break + 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) { @@ -179,9 +231,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. @@ -190,22 +239,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(distance: intValue)) + case .cursorDown: + self.delegate?.parser(self, didMoveCursorInDirection: .down(distance: intValue)) + case .cursorForward: + self.delegate?.parser(self, didMoveCursorInDirection: .right(distance: intValue)) + case .cursorBack: + self.delegate?.parser(self, didMoveCursorInDirection: .left(distance: 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) + self.delegate?.parser(self, didMoveCursorInDirection: .down(distance: 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) + self.delegate?.parser(self, didMoveCursorInDirection: .up(distance: 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..49514a12 --- /dev/null +++ b/OpenTerm/Util/Terminal/TerminalBuffer.swift @@ -0,0 +1,176 @@ +// +// TerminalBuffer.swift +// OpenTerm +// +// Created by Ian McDowell on 2/9/18. +// Copyright © 2018 Silver Fox. All rights reserved. +// + +import UIKit + +protocol TerminalBufferDelegate: class { + + /// When the cursor moves, this method will be called. + func terminalBuffer(_ buffer: TerminalBuffer, cursorDidChange cursor: TerminalCursor) + + /// 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 let stdinParser: Parser + + private var cursor: TerminalCursor { + didSet { + delegate?.terminalBuffer(self, cursorDidChange: cursor) + } + } + + init() { + storage = NSTextStorage() + layoutManager = NSLayoutManager() + textContainer = NSTextContainer() + + stdoutParser = Parser(type: .stdout) + stderrParser = Parser(type: .stderr) + stdinParser = Parser(type: .stdin) + + cursor = .zero + + storage.addLayoutManager(layoutManager) + layoutManager.addTextContainer(textContainer) + + stdoutParser.delegate = self + stderrParser.delegate = self + stdinParser.delegate = self + } + + /// Reset the state of the parsers & the cursor position + func reset() { + stdoutParser.reset() + stderrParser.reset() + stdinParser.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) + } + + /// Add raw data from stdin + func add(stdin: Data) { + stdinParser.parse(stdin) + } + + /// 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 + let insertionLength = attributedString.string.utf16.count + 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, insertionLength)) + + self.storage.replaceCharacters(in: range, with: attributedString) + + // Move cursor right by the number of characters in the inserted string + self.cursor.move(.right(distance: insertionLength), in: self.storage) + } +} + +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.performOnMain { + self.insert(string) + } + } + func parserDidReceiveCarriageReturn(_ parser: Parser) { + DispatchQueue.performOnMain { + self.cursor.move(.beginningOfLine, in: self.storage) + } + } + func parserDidReceiveNewLine(_ parser: Parser) { + DispatchQueue.performOnMain { + self.storage.append(NSAttributedString.init(string: "\n")) + self.cursor.move(.beginningOfLine, in: self.storage) + self.cursor.move(.down(distance: 1), in: self.storage) + } + } + func parserDidReceiveBackspace(_ parser: Parser) { + DispatchQueue.performOnMain { + // TODO: Is this correct? Should we also modify storage at all? + self.cursor.move(.left(distance: 1), in: self.storage) + } + } + func parser(_ parser: Parser, didMoveCursorInDirection direction: TerminalCursor.Direction) { + DispatchQueue.performOnMain { + self.cursor.move(direction, in: self.storage) + } + } + func parser(_ parser: Parser, didMoveCursorTo position: Int, onAxis axis: TerminalCursor.Axis) { + DispatchQueue.performOnMain { + self.cursor.set(axis, to: position, in: self.storage) + } + } + 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() + } + } +} + +extension TerminalBuffer: CustomDebugStringConvertible { + + var debugDescription: String { + let lines = storage.string.components(separatedBy: .newlines) + let currentLine = lines[cursor.y] + return "length: \(storage.string.count), lines: \(lines.count), cursor: \(cursor.debugDescription), current line: \(currentLine)" + } +} diff --git a/OpenTerm/Util/Terminal/TerminalCursor.swift b/OpenTerm/Util/Terminal/TerminalCursor.swift new file mode 100644 index 00000000..6d3957d8 --- /dev/null +++ b/OpenTerm/Util/Terminal/TerminalCursor.swift @@ -0,0 +1,159 @@ +// +// TerminalBuffer.swift +// OpenTerm +// +// Created by Ian McDowell on 2/9/18. +// Copyright © 2018 Silver Fox. All rights reserved. +// + +import UIKit + +/// The cursor represents an x / y position in the terminal, where text is inserted as it comes in. +/// This implementation stores 3 values, x, y, and an offset. It's important that the developer synchronize +/// updates to the cursor with updates to the underlying storage (TerminalBuffer / NSTextStorage) +struct TerminalCursor { + /// 0-based location in the current line + private(set) var x: Int + + /// 0-based row in the current viewport + private(set) var y: Int + + /// 0-based offset in the entire storage, must stay in sync with x/y position. + private(set) var offset: Int + + /// Convenience zero (top left) value. + static let zero = TerminalCursor(x: 0, y: 0, offset: 0) + + enum Direction { + case up(distance: Int), down(distance: Int), left(distance: Int), right(distance: Int), beginningOfLine, endOfString + } + enum Axis { + case x, y + } + + /// Each row that the cursor moves through is separated by newlines. + private let newlineCharacterSet = CharacterSet.newlines + + /// Move the cursor in the given direction inside the given storage. + /// If it can't move any more, it does nothing. + mutating func move(_ direction: Direction, in storage: NSTextStorage) { + dispatchPrecondition(condition: .onQueue(.main)) + + let storedString = storage.string + let string = storedString.utf16 + let offset = string.index(string.startIndex, offsetBy: self.offset) + switch direction { + case .up: + fatalError("Not implemented") + case .down(let distance): + + // Find index after newline `distance` times. + var nextNewLine: String.Index = self.indexAfterPreviousNewline(from: offset, in: storedString) + for _ in 0.. Int { + let string = storage.string.utf16 + let offset = string.index(string.startIndex, offsetBy: self.offset) + let index = self.indexOfNextNewline(from: offset, in: storage.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.. Void) { - if Thread.isMainThread { - block() - } else { - DispatchQueue.main.async(execute: block) - } - } - private func appendText(_ text: NSAttributedString) { dispatchPrecondition(condition: .onQueue(.main)) 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) + self.textView.buffer.moveCursorToEnd() let rect = textView.caretRect(for: textView.endOfDocument) textView.scrollRectToVisible(rect, animated: true) @@ -132,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 } @@ -156,8 +142,7 @@ class TerminalView: UIView { func clearScreen() { currentCommandStartIndex = nil textView.text = "" - stdoutParser.reset() - stderrParser.reset() + textView.buffer.reset() } @discardableResult @@ -259,26 +244,27 @@ extension TerminalView { } } -extension TerminalView: ParserDelegate { +extension TerminalView: TerminalBufferDelegate { - func parserDidEndTransmission(_ parser: Parser) { - DispatchQueue.main.async { - self.writePrompt() + func terminalBuffer(_ buffer: TerminalBuffer, cursorDidChange cursor: TerminalCursor) { + if let position = textView.position(from: textView.beginningOfDocument, offset: cursor.offset) { + textView.selectedTextRange = textView.textRange(from: position, to: position) } } - func parser(_ parser: Parser, didReceiveString string: NSAttributedString) { - self.writeOutput(string) + func terminalBufferDidReceiveETX() { + self.writePrompt() } + } extension TerminalView: CommandExecutorDelegate { func commandExecutor(_ commandExecutor: CommandExecutor, receivedStdout stdout: Data) { - stdoutParser.parse(stdout) + self.textView.buffer.add(stdout: stdout) } func commandExecutor(_ commandExecutor: CommandExecutor, receivedStderr stderr: Data) { - stderrParser.parse(stderr) + self.textView.buffer.add(stderr: stderr) } func commandExecutor(_ commandExecutor: CommandExecutor, didChangeWorkingDirectory to: URL) { @@ -327,7 +313,10 @@ extension TerminalView: UITextViewDelegate { switch executor.state { case .running: executor.sendInput(text) - return true + if let data = text.data(using: .utf8) { + self.textView.buffer.add(stdin: data) + } + return false case .idle: let i = textView.text.distance(from: textView.text.startIndex, to: currentCommandStartIndex) @@ -345,9 +334,9 @@ extension TerminalView: UITextViewDelegate { newLine() delegate?.didEnterCommand(String(input)) } + // Don't enter the \n character return false } - return true } } 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 new file mode 100644 index 00000000..2db7e15d --- /dev/null +++ b/OpenTermTests/ParserTests.swift @@ -0,0 +1,179 @@ +// +// 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(type: .stdout) + 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) + 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) { + receivedMethods.append(.cursorMove(direction: direction)) + } + 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)!) + } + + 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)!) + } + + private func receivedString(withControlCharacters controlCharacters: Bool = true) -> NSAttributedString { + let receivedStr = NSMutableAttributedString() + for method in parserDelegate.receivedMethods { + switch method { + case .string(let str): + receivedStr.append(str) + case .newLine: + if controlCharacters { + receivedStr.append(NSAttributedString.init(string: "\n")) + } + case .carriageReturn: + if controlCharacters { + receivedStr.append(NSAttributedString.init(string: "\r")) + } + case .endTransmission: + break + default: + XCTFail("Unexpected method called on parser delegate") + } + } + return receivedStr + } + + func testBasicText() { + let str = "hello world" + + send(str) + end() + + XCTAssertEqual(str, receivedString().string, "Received string should equal sent string") + } + + func testTextWithNewLine() { + let str = "hello\nworld" + + send(str) + end() + + XCTAssertEqual(str, receivedString().string, "Received string should equal sent string") + } + + func testSanitizedOutput() { + let str = DocumentManager.shared.activeDocumentsFolderURL.path + + send(str) + end() + + 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 + } + +} diff --git a/OpenTermTests/TerminalBufferTests.swift b/OpenTermTests/TerminalBufferTests.swift new file mode 100644 index 00000000..34944b6d --- /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(type: .stdout) + + 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) + + XCTAssertEqual(bufferContents, string, "Buffer should equal the received string") + } + + func testStringsWithNewLine() { + let line1 = "hello world" + let line2 = "test" + + receiveString(line1) + newLine() + receiveString(line2) + + XCTAssertEqual(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) + + XCTAssertEqual(bufferContents, line2 + line1pt2, "Buffer should equal the second line + leftover first line") + } + +} diff --git a/OpenTermTests/TerminalCursorTests.swift b/OpenTermTests/TerminalCursorTests.swift new file mode 100644 index 00000000..93a29f6f --- /dev/null +++ b/OpenTermTests/TerminalCursorTests.swift @@ -0,0 +1,149 @@ +// +// 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)) + + cursor.move(.right(distance: str.utf16.count), in: storage) + + 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") + + cursor.move(.left(distance: str.utf16.count), in: storage) + + XCTAssertEqual(cursor.x, 0, "Cursor moved back to beginning") + XCTAssertEqual(cursor.y, 0, "Cursor didn't move vertically") + XCTAssertEqual(cursor.offset, 0, "Offset == beginning of string") + } + + func testMoveLeftFromZero() { + + let x = cursor.x + let offset = cursor.offset + cursor.move(.left(distance: 1), in: storage) + + XCTAssertEqual(cursor.x, x, "Cursor didn't move") + XCTAssertEqual(cursor.offset, offset, "Cursor didn't move") + } + + func testMoveDownALine() { + + let line1 = "hello" + let line2 = "world" + + storage.append(NSAttributedString.init(string: line1)) + // Move to end of line1 + cursor.move(.right(distance: line1.utf16.count), in: storage) + + XCTAssertEqual(cursor.x, line1.count, "Cursor should be at the end of the first line") + XCTAssertEqual(cursor.y, 0, "Cursor should be on the first line") + + storage.append(NSAttributedString.init(string: "\n" + line2)) + cursor.move(.down(distance: 1), in: storage) + XCTAssertEqual(cursor.x, line1.count, "Cursor didn't move horizontally") + XCTAssertEqual(cursor.y, 1, "Cursor moved down a row") + } + + func testMoveEndOfString() { + let string = "hello\nworld\ntest\nstring\n" + + storage.append(NSAttributedString.init(string: string)) + cursor.move(.endOfString, in: storage) + + XCTAssertEqual(cursor.x, 0, "Cursor x should be 0 since last character is newline") + XCTAssertEqual(cursor.y, 4, "Cursor y should be 4 since there are 4 lines") + XCTAssertEqual(cursor.offset, string.count, "Offset should be end of string") + } + + func testMoveOutOfEmptyString() { + cursor.move(.right(distance: 1), in: storage) + XCTAssert(cursor.x == 0 && cursor.y == 0, "Cursor should not move if out of bounds") + cursor.move(.left(distance: 1), in: storage) + XCTAssert(cursor.x == 0 && cursor.y == 0, "Cursor should not move if out of bounds") +// cursor.move(.up(distance: 1), in: storage) +// XCTAssert(cursor.y == 0, "Cursor should not move if out of bounds") + cursor.move(.down(distance: 1), in: storage) + XCTAssert(cursor.y == 0 && cursor.y == 0, "Cursor should not move if out of bounds") + } + + func testSetXAxis() { + let string = "hello\nworld\ntest\nstring" + + storage.append(NSAttributedString.init(string: string)) + cursor.move(.right(distance: 2), in: storage) + + cursor.move(.down(distance: 1), in: storage) + + XCTAssertEqual(cursor.x, 2, "Cursor should have moved right 2") + XCTAssertEqual(cursor.y, 1, "Cursor should have moved down 1") + + let offset = cursor.offset + cursor.set(.x, to: 5, in: storage) + + XCTAssertEqual(cursor.x, 5, "Cursor should have moved to position that we told it to") + XCTAssertEqual(cursor.y, 1, "Cursor should not have moved vertically") + XCTAssertEqual(cursor.offset, offset + 3, "Cursor should have moved right 3") + } + func testSetXAxisOutOfBounds() { + let string = "hello\nworld" + + storage.append(NSAttributedString.init(string: string)) + + cursor.set(.x, to: 5, in: storage) + + XCTAssertEqual(cursor.x, 5, "Cursor should move properly in bounds") + + cursor.set(.x, to: 500, in: storage) + + XCTAssertEqual(cursor.x, 5, "Cursor should not move if given out of bounds value") + } + + func testDistanceToEndOfLineSingle() { + let string = "really long single line string" + storage.append(NSAttributedString.init(string: string)) + + let distance = cursor.distanceToEndOfLine(in: storage) + + XCTAssertEqual(distance, string.count, "Distance to end of line should be the length of the string") + } + + func testDistanceToEndOfLineMultipleLines() { + let line1 = "really long" + let line2 = "multiple line string" + storage.append(NSAttributedString.init(string: line1 + "\n" + line2)) + + let distance = cursor.distanceToEndOfLine(in: storage) + + XCTAssertEqual(distance, line1.count, "Distance to end of line should be the length of the first line") + } +}