Building Custom Terminal

Motivation

Requirement was to have a command line environment which can provide features like:

  • Maintaining a state, a state can be command history, variables, config, defaults, etc.
  • Way to store and restore state.
  • Variable support
    I wanted a way to assign arbitrary string to a name and use that name in command-line as an substitute for arbitrary string.  For example I could just say:

    >> set project_path /home/prajit/projects/TerminalEmulator

    and the use it in command line

    >> ls $project_path

  • Smart autocompletion, like variable name completion, command completion,
    command argument completion etc. based on the context.
  • Caching.

Let’s code

Overview

We are not building this from scratch, we will use GNU Readline Library which is primarily written in C and has support of various other languages, in our case the choice of language is Perl(Term::ReadLine::Gnu), I will try to keep code as simple as possible, you can find the code link on github PrajitP/customTerminal.

Let’s create template code

use Term::ReadLine;

my $term = Term::ReadLine->new('Custom Terminal Name');

while(1) {
    my $cmd = $term->readline('>> ');
    system($cmd);
}

Above code will run in an infinite loop, it will wait for user to type and press enter, once user press enter it will run/execute user input as system command.
So far it’s a dumb terminal, we have to press ‘Ctrl + c’ to kill the terminal, so lets add a support for our first command ‘exit’.

use Term::ReadLine;

my %commands = (
    exit => {
        action => sub {   # This is a subroutine reference in Perl
            last;         # This will break the while loop
        },
    },
);

my $term = Term::ReadLine->new('Custom Terminal Name');

while(1) {
    my $cmd = $term->readline('>> ');
    my ($cmd_name, @cmd_args) = split(' ', $cmd);
    if(exists $commands{$cmd_name}) {           # Is first word a known command
        $commands{$cmd_name}->{action}->($cmd); # Run the code for given command
    }
    else {
         system($cmd);
    }
}

Let’s add variable feature

Let’s add a feature where we can set a variable and use it directly in command-line, we will create a new command ‘set‘ which take argument ‘variableName variableValue‘, and we will add support for new syntax ‘$variableName‘ in command-line which will be replace by ‘varaiableValue‘ internally.

use Term::ReadLine;

my %variables = ();
my %commands = (
    # ... skipping code here ...
    set => {
        action => sub {
            my $cmd = shift;
            my ($cmd_name, @cmd_args) = split(' ', $cmd);
            # TODO: Add parameter validation
            $variables{$cmd_args[0]} = $cmd_args[1];
        },
    },
);

sub expand_variables {
    my $cmd = shift;
    # NOTE: 'reverse' & 'sort' make sure we always substitute biggest string first,
    #       this make sure prefix does not get priority,
    #       example: if we have 2 variables 'dir' & 'dir1', 'dir1' will be substituted first.
    for my $key (reverse sort keys %variables) {
        $cmd =~ s/\$\Q$key\E/$variables{$key}/gc;
    }
    return $cmd;
}

my $term = Term::ReadLine->new('Custom Terminal Name');

while(1) {
    my $cmd = $term->readline('>> ');
    $cmd = expand_variables($cmd);
    # ... skipping code here ...
}

Let’s add auto complete feature

Let’s add an auto completion feature to complete the commands and variable names, if it’s a first word suggest command completion, if we find a ‘$‘ prefix at front to word suggest variable name completion.

use Term::ReadLine;

my %variables = ();
my %commands = (
    # ... skipping code here ...
);

sub expand_variables {
    # ... skipping code here ...
}

my $term = Term::ReadLine->new('Custom Terminal Name');
# The completion logic is referred from 'http://www.perlmonks.org/?node_id=629849'
$term_attr->{attempted_completion_function} = \&complete;

while(1) {
    my $cmd = $term->readline('>> ');
    $cmd = expand_variables($cmd);
    # ... skipping code here ...
}

sub complete {
    my ($text, $line, $start, $end) = @_;

    # Example, let's say user typed: 'ls $fo'
    #          $line = 'ls $fo'
    #          $text = 'fo'
    #          $start = 4
    #          $end   = 6

    my @words = split(' ', $line);
    my $last_word = $words[-1];         # For above example this will be '$fo'

    if($start == 0){
        # First word should be a command
        return $term->completion_matches($text,\&complete_command);
    }
    elsif($last_word =~ /^\$/){
        # If last word start with '$', it can be a variable
        return $term->completion_matches($text,\&complete_variable);
    }
    # Else this will do default to file name completion
    return;
}

sub complete_variable {
    return complete_keyword(@_, [sort keys %variables]);
}

sub complete_command {
    return complete_keyword(@_, [sort keys %commands]);
}

{
    my $i;
    my @keywords;
    # Below function will be called every time user press ,
    # '$state' value will be '0' for first  press and 'non-zero' for consecutive  press.
    sub complete_keyword {
        my ($text, $state, $keyword_list) = @_;
        return unless $text;
        if($state) {
            $i++;
        }
        else {
            # first call, set starting point
            @keywords = @{$keyword_list};
            $i = 0;
        }
        for (; $i<=$#keywords; $i++) {
            # Return first word starting from index '$i' whose prefix matches '$text'
            return $keywords[$i] if $keywords[$i] =~ /^\Q$text/;
        };
        return undef;
    }
}

Scope for improvements

  • Add redirection(STDOUT & STDERR redirect to file) and pipe support to custom commands like set, get.
  • UI improvements
    • If command line gets bigger it warps on same line instead of new line, fix this.
    • Overwrite command with expanded variable value, so one can know the final command that ran.
Advertisements