blob: f3ff3ea5e888ad76006a5d047364375cf35f2113 [file] [log] [blame]
// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'terminfo.dart';
typedef List<String> CommandCompleter(List<String> commandParts);
class Commando {
// Ctrl keys
static const runeCtrlA = 0x01;
static const runeCtrlB = 0x02;
static const runeCtrlD = 0x04;
static const runeCtrlE = 0x05;
static const runeCtrlF = 0x06;
static const runeTAB = 0x09;
static const runeNewline = 0x0a;
static const runeCtrlK = 0x0b;
static const runeCtrlL = 0x0c;
static const runeCtrlN = 0x0e;
static const runeCtrlP = 0x10;
static const runeCtrlU = 0x15;
static const runeCtrlY = 0x19;
static const runeESC = 0x1b;
static const runeSpace = 0x20;
static const runeDEL = 0x7F;
StreamController<String> _commandController;
Stream get commands => _commandController.stream;
Commando({consoleIn,
consoleOut,
this.prompt : '> ',
this.completer : null}) {
_stdin = (consoleIn != null ? consoleIn : stdin);
_stdout = (consoleOut != null ? consoleOut : stdout);
_commandController = new StreamController<String>(
onCancel: _onCancel);
_stdin.echoMode = false;
_stdin.lineMode = false;
_screenWidth = _term.cols - 1;
_writePrompt();
// TODO(turnidge): Handle errors in _stdin here.
_stdinSubscription =
_stdin.transform(UTF8.decoder).listen(_handleText, onDone:_done);
}
Future _onCancel() {
_stdin.echoMode = true;
_stdin.lineMode = true;
var future = _stdinSubscription.cancel();
if (future != null) {
return future;
} else {
return new Future.value();
}
}
// Before terminating, call close() to restore terminal settings.
void _done() {
_onCancel().then((_) {
_commandController.close();
});
}
void _handleText(String text) {
try {
if (!_promptShown) {
_bufferedInput.write(text);
return;
}
var runes = text.runes.toList();
var pos = 0;
while (pos < runes.length) {
if (!_promptShown) {
// A command was processed which hid the prompt. Buffer
// the rest of the input.
//
// TODO(turnidge): Here and elsewhere in the file I pass
// runes to String.fromCharCodes. Does this work?
_bufferedInput.write(
new String.fromCharCodes(runes.skip(pos)));
return;
}
var rune = runes[pos];
// Count consecutive tabs because double-tab is meaningful.
if (rune == runeTAB) {
_tabCount++;
} else {
_tabCount = 0;
}
if (_isControlRune(rune)) {
pos += _handleControlSequence(runes, pos);
} else {
pos += _handleRegularSequence(runes, pos);
}
}
} catch(e, trace) {
_commandController.addError(e, trace);
}
}
int _handleControlSequence(List<int> runes, int pos) {
var runesConsumed = 1; // Most common result.
var char = runes[pos];
switch (char) {
case runeCtrlA:
_home();
break;
case runeCtrlB:
_leftArrow();
break;
case runeCtrlD:
if (_currentLine.length == 0) {
// ^D on an empty line means quit.
_stdout.writeln("^D");
_done();
} else {
_delete();
}
break;
case runeCtrlE:
_end();
break;
case runeCtrlF:
_rightArrow();
break;
case runeTAB:
if (_complete(_tabCount > 1)) {
_tabCount = 0;
}
break;
case runeNewline:
_newline();
break;
case runeCtrlK:
_kill();
break;
case runeCtrlL:
_clearScreen();
break;
case runeCtrlN:
_historyNext();
break;
case runeCtrlP:
_historyPrevious();
break;
case runeCtrlU:
_clearLine();
break;
case runeCtrlY:
_yank();
break;
case runeESC:
// Check to see if this is an arrow key.
if (pos + 2 < runes.length && // must be a 3 char sequence.
runes[pos + 1] == 0x5b) { // second char must be '['.
switch (runes[pos + 2]) {
case 0x41: // ^[[A = up arrow
_historyPrevious();
runesConsumed = 3;
break;
case 0x42: // ^[[B = down arrow
_historyNext();
runesConsumed = 3;
break;
case 0x43: // ^[[C = right arrow
_rightArrow();
runesConsumed = 3;
break;
case 0x44: // ^[[D = left arrow
_leftArrow();
runesConsumed = 3;
break;
default:
// Ignore the escape character.
break;
}
}
break;
case runeDEL:
_backspace();
break;
default:
// Ignore the escape character.
break;
}
return runesConsumed;
}
int _handleRegularSequence(List<int> runes, int pos) {
var len = pos + 1;
while (len < runes.length && !_isControlRune(runes[len])) {
len++;
}
_addChars(runes.getRange(pos, len));
return len;
}
bool _isControlRune(int char) {
return (char >= 0x00 && char < 0x20) || (char == 0x7f);
}
void _writePromptAndLine() {
_writePrompt();
var pos = _writeRange(_currentLine, 0, _currentLine.length);
_cursorPos = _move(pos, _cursorPos);
}
void _writePrompt() {
_stdout.write(prompt);
}
void _addChars(Iterable<int> chars) {
var newLine = [];
newLine..addAll(_currentLine.take(_cursorPos))
..addAll(chars)
..addAll(_currentLine.skip(_cursorPos));
_update(newLine, (_cursorPos + chars.length));
}
void _backspace() {
if (_cursorPos == 0) {
return;
}
var newLine = [];
newLine..addAll(_currentLine.take(_cursorPos - 1))
..addAll(_currentLine.skip(_cursorPos));
_update(newLine, (_cursorPos - 1));
}
void _delete() {
if (_cursorPos == _currentLine.length) {
return;
}
var newLine = [];
newLine..addAll(_currentLine.take(_cursorPos))
..addAll(_currentLine.skip(_cursorPos + 1));
_update(newLine, _cursorPos);
}
void _home() {
_updatePos(0);
}
void _end() {
_updatePos(_currentLine.length);
}
void _clearScreen() {
_stdout.write(_term.clear);
_term.resize();
_screenWidth = _term.cols - 1;
_writePromptAndLine();
}
void _kill() {
var newLine = [];
newLine.addAll(_currentLine.take(_cursorPos));
_killBuffer = _currentLine.skip(_cursorPos).toList();
_update(newLine, _cursorPos);
}
void _clearLine() {
_update([], 0);
}
void _yank() {
var newLine = [];
newLine..addAll(_currentLine.take(_cursorPos))
..addAll(_killBuffer)
..addAll(_currentLine.skip(_cursorPos));
_update(newLine, (_cursorPos + _killBuffer.length));
}
static String _trimLeadingSpaces(String line) {
bool _isSpace(int rune) {
return rune == runeSpace;
}
return new String.fromCharCodes(line.runes.skipWhile(_isSpace));
}
static String _sharedPrefix(String one, String two) {
var len = min(one.length, two.length);
var runesOne = one.runes.toList();
var runesTwo = two.runes.toList();
var pos;
for (pos = 0; pos < len; pos++) {
if (runesOne[pos] != runesTwo[pos]) {
break;
}
}
var shared = new String.fromCharCodes(runesOne.take(pos));
return shared;
}
bool _complete(bool showCompletions) {
if (completer == null) {
return false;
}
var linePrefix = _currentLine.take(_cursorPos).toList();
List<String> commandParts =
_trimLeadingSpaces(new String.fromCharCodes(linePrefix)).split(' ');
List<String> completionList = completer(commandParts);
var completion = '';
if (completionList.length == 0) {
// The current line admits no possible completion.
return false;
} else if (completionList.length == 1) {
// There is a single, non-ambiguous completion for the current line.
completion = completionList[0];
// If we are at the end of the line, add a space to signal that
// the completion is unambiguous.
if (_currentLine.length == _cursorPos) {
completion = completion + ' ';
}
} else {
// There are ambiguous completions. Find the longest common
// shared prefix of all of the completions.
completion = completionList.fold(completionList[0], _sharedPrefix);
}
var lastWord = commandParts.last;
if (completion == lastWord) {
// The completion does not add anything.
if (showCompletions) {
// User hit double-TAB. Show them all possible completions.
_move(_cursorPos, _currentLine.length);
_stdout.writeln();
_stdout.writeln(completionList);
_writePromptAndLine();
}
return false;
} else {
// Apply the current completion.
var completionRunes = completion.runes.toList();
var newLine = [];
newLine..addAll(linePrefix)
..addAll(completionRunes.skip(lastWord.length))
..addAll(_currentLine.skip(_cursorPos));
_update(newLine, _cursorPos + completionRunes.length - lastWord.length);
return true;
}
}
void _newline() {
_addLineToHistory(_currentLine);
_linePos = _lines.length;
_end();
_stdout.writeln();
// Call the user's command handler.
_commandController.add(new String.fromCharCodes(_currentLine));
_currentLine = [];
_cursorPos = 0;
if (_promptShown) {
_writePrompt();
}
}
void _leftArrow() {
_updatePos(_cursorPos - 1);
}
void _rightArrow() {
_updatePos(_cursorPos + 1);
}
void _addLineToHistory(List<int> line) {
if (_tempLineAdded) {
_lines.removeLast();
_tempLineAdded = false;
}
if (line.length > 0) {
_lines.add(line);
}
}
void _addTempLineToHistory(List<int> line) {
_lines.add(line);
_tempLineAdded = true;
}
void _replaceHistory(List<int> line, int linePos) {
_lines[linePos] = line;
}
void _historyPrevious() {
if (_linePos == 0) {
return;
}
if (_linePos == _lines.length) {
// The current in-progress line gets temporarily stored in history.
_addTempLineToHistory(_currentLine);
} else {
// Any edits get committed to history.
_replaceHistory(_currentLine, _linePos);
}
_linePos -= 1;
var line = _lines[_linePos];
_update(line, line.length);
}
void _historyNext() {
// For the very first command, _linePos (0) will exceed
// (_lines.length - 1) (-1) so we use a ">=" here instead of an "==".
if (_linePos >= (_lines.length - 1)) {
return;
}
// Any edits get committed to history.
_replaceHistory(_currentLine, _linePos);
_linePos += 1;
var line = _lines[_linePos];
_update(line, line.length);
}
void _updatePos(int newCursorPos) {
if (newCursorPos < 0) {
return;
}
if (newCursorPos > _currentLine.length) {
return;
}
_cursorPos = _move(_cursorPos, newCursorPos);
}
void _update(List<int> newLine, int newCursorPos) {
var pos = _cursorPos;
var diffPos;
var sharedLen = min(_currentLine.length, newLine.length);
// Find first difference.
for (diffPos = 0; diffPos < sharedLen; diffPos++) {
if (_currentLine[diffPos] != newLine[diffPos]) {
break;
}
}
// Move the cursor to where the difference begins.
pos = _move(pos, diffPos);
// Write the new text.
pos = _writeRange(newLine, pos, newLine.length);
// Clear any extra characters at the end.
pos = _clearRange(pos, _currentLine.length);
// Move the cursor back to the input point.
_cursorPos = _move(pos, newCursorPos);
_currentLine = newLine;
}
void hide() {
if (!_promptShown) {
return;
}
_promptShown = false;
// We need to erase everything, including the prompt.
var curLine = _getLine(_cursorPos);
var lastLine = _getLine(_currentLine.length);
// Go to last line.
if (curLine < lastLine) {
for (var i = 0; i < (lastLine - curLine); i++) {
// This moves us to column 0.
_stdout.write(_term.cursorDown);
}
curLine = lastLine;
} else {
// Move to column 0.
_stdout.write('\r');
}
// Work our way up, clearing lines.
while (true) {
_stdout.write(_term.clrEOL);
if (curLine > 0) {
_stdout.write(_term.cursorUp);
} else {
break;
}
}
}
void show() {
if (_promptShown) {
return;
}
_promptShown = true;
_writePromptAndLine();
// If input was buffered while the prompt was hidden, process it
// now.
if (!_bufferedInput.isEmpty) {
var input = _bufferedInput.toString();
_bufferedInput.clear();
_handleText(input);
}
}
int _writeRange(List<int> text, int pos, int writeToPos) {
if (pos >= writeToPos) {
return pos;
}
while (pos < writeToPos) {
var margin = _nextMargin(pos);
var limit = min(writeToPos, margin);
_stdout.write(new String.fromCharCodes(text.getRange(pos, limit)));
pos = limit;
if (pos == margin) {
_stdout.write('\n');
}
}
return pos;
}
int _clearRange(int pos, int clearToPos) {
if (pos >= clearToPos) {
return pos;
}
while (true) {
var limit = _nextMargin(pos);
_stdout.write(_term.clrEOL);
if (limit >= clearToPos) {
return pos;
}
_stdout.write('\n');
pos = limit;
}
}
int _move(int pos, int newPos) {
if (pos == newPos) {
return pos;
}
var curCol = _getCol(pos);
var curLine = _getLine(pos);
var newCol = _getCol(newPos);
var newLine = _getLine(newPos);
if (curLine > newLine) {
for (var i = 0; i < (curLine - newLine); i++) {
_stdout.write(_term.cursorUp);
}
}
if (curLine < newLine) {
for (var i = 0; i < (newLine - curLine); i++) {
_stdout.write(_term.cursorDown);
}
// Moving down resets column to zero, oddly.
curCol = 0;
}
if (curCol > newCol) {
for (var i = 0; i < (curCol - newCol); i++) {
_stdout.write(_term.cursorBack);
}
}
if (curCol < newCol) {
for (var i = 0; i < (newCol - curCol); i++) {
_stdout.write(_term.cursorForward);
}
}
return newPos;
}
int _nextMargin(int pos) {
var truePos = pos + prompt.length;
return ((truePos ~/ _screenWidth) + 1) * _screenWidth - prompt.length;
}
int _getLine(int pos) {
var truePos = pos + prompt.length;
return truePos ~/ _screenWidth;
}
int _getCol(int pos) {
var truePos = pos + prompt.length;
return truePos % _screenWidth;
}
Stdin _stdin;
StreamSubscription _stdinSubscription;
IOSink _stdout;
final String prompt;
bool _promptShown = true;
final CommandCompleter completer;
TermInfo _term = new TermInfo();
// TODO(turnidge): See if we can get screen resize events.
int _screenWidth;
List<int> _currentLine = []; // A list of runes.
StringBuffer _bufferedInput = new StringBuffer();
List<List<int>> _lines = [];
// When using the command history, the current line is temporarily
// added to the history to allow the user to return to it. This
// values tracks whether the history has a temporary line at the end.
bool _tempLineAdded = false;
int _linePos = 0;
int _cursorPos = 0;
int _tabCount = 0;
List<int> _killBuffer = [];
}
// Demo code.
List<String> _myCompleter(List<String> commandTokens) {
List<String> completions = new List<String>();
// First word completions.
if (commandTokens.length <= 1) {
String prefix = '';
if (commandTokens.length == 1) {
prefix = commandTokens.first;
}
if ('quit'.startsWith(prefix)) {
completions.add('quit');
}
if ('help'.startsWith(prefix)) {
completions.add('help');
}
if ('happyface'.startsWith(prefix)) {
completions.add('happyface');
}
}
// Complete 'foobar' or 'gondola' anywhere in string.
String lastWord = commandTokens.last;
if ('foobar'.startsWith(lastWord)) {
completions.add('foobar');
}
if ('gondola'.startsWith(lastWord)) {
completions.add('gondola');
}
return completions;
}
int _helpCount = 0;
Commando cmdo;
void _handleCommand(String rawCommand) {
String command = rawCommand.trim();
cmdo.hide();
if (command == 'quit') {
cmdo.close().then((_) {
print('Exiting');
});
} else if (command == 'help') {
switch (_helpCount) {
case 0:
print('I will not help you.');
break;
case 1:
print('I mean it.');
break;
case 2:
print('Seriously.');
break;
case 100:
print('Well now.');
break;
default:
print("Okay. Type 'quit' to quit");
break;
}
_helpCount++;
} else if (command == 'happyface') {
print(':-)');
} else {
print('Received command($command)');
}
cmdo.show();
}
void main() {
print('[Commando demo]');
cmdo = new Commando(completer:_myCompleter);
cmdo.commands.listen(_handleCommand);
}