Rash - A Raku Shell

Part 2: The Loop

by Arne Sommer

Rash - Part 2: The Shell

[67.2] Published 13. April 2020.

See also: The Introduction | Part 1: The Path.

The first first version of the shell just echos back what we type. Use «Ctrl-C» to exit it:

File: rash-loop
say 'rash: Enter <Control-C> to exit';

loop
{
  say prompt "> ";  # [1]
}

[1] prompt prints the specified text (if any), waits for user input, and returns it.

See docs.raku.org/routine/prompt for more information about prompt.

The program is short, concise and to the point. What point, you may ask, as it doesn't really do anything usefull yet.

An eternal loop without an exit strategy. Let us add an «exit» command, terminating the shell:

File: rash-exit
say 'rash: Enter «exit» to exit';

my $cmd;

loop
{
  $cmd = prompt "> ";
  last if $cmd eq "exit"; # [1]
  say $cmd;
}

[1] I have used last to break out of the loop. exit is another possibility. It terminates the whole program, which in this case amounts to the same thing.

Let us add support for a second command, e.g. «version». Instead of if (and a lot of elsif as we add more commands), we can use Raku's take on switch: given/when:

File: rash-version
say 'rash: Enter «exit» to exit';

loop
{
  given prompt "> "
  {
    when "exit"    { last; }
    when "version" { say "Version 0.01"; }
    default        { .say  }                 # [1]
  }
}

[1] default in a given block behaves in the same way as an else in an if block.

We could have written our when clauses as statement modifiers:

File: rash-version2
say 'rash: Enter «exit» to exit';

loop
{
  given prompt "> "
  {
    last               when "exit";
    say "Version 0.02" when "version";
    default { .say; }
  }
}

But the normal form, with the condition first, usually makes for more readable code.

given sets $_ inside the following block, and can be used alone (without when) just to that effect:

> given 42 { .say; .^name.say; }
42
Int

Getting Help

We should give an error for unknown commands, and we add a «help» command showing legal commands:

File: rash-help
my @commands = <exit help version>;   # [1]

say 'rash: Enter «exit» to exit';

loop
{
  given prompt "> "
  {
    when "exit"    { last; }
    when "help"    { say "Legal commands: { @commands }" }
    when "version" { say "Version 0.03"; }
    default        { say "Unknown command: \"$_\" (use \"help\" for a list of commands)";  }
  }
}

[1] The list of legal commands.

Getting rid of one level of braces

Can we combine loop and given, so that we can get rid of one set of braces?

.. with «for/when»

We can use when in a for loop:

for 42, 43, "foo", 44, "bar"
{
  when Int       { .say }
  when /:i ^Bar/ { .say }
  default        { say "Not an Int or a Bar" }
}

# -> «42␤43␤Not an Int or a Bar␤44␤Bar␤» 

Using for prompt "> " in our code will not work as prompt returns a single value.

.. with «gather/take»

We can make an iterator of prompt "> " with gather/take, and we have managed to remove one level of braces.

File: rash-gather
my @commands = <exit help version>;

say 'rash: Enter «exit» to exit';

my $prompt := gather take prompt "> " while True; # [1]

for $prompt
{
  when "exit"    { last; }
  when "help"    { say "Legal commands: { @commands }" }
  when "version" { say "Version 0.04"; }
  default        { say "Unknown command: \"$_\" (use \"help\" for a list of commands)";  }
}

[1] while True gives an eternal loop.

Command History

If we enter a command with a typo, e.g «histroy» instead of «history», we'd want to edit the command and try again. As supported by the up and down arrow keys in a normal shell. But that doesn't work.

It works in «REPL», courtesy of the «Linenoise» module. We can do the same.

We start with a look at the module documentation:

$ p6doc Linenoise

Note the program name, where «p6» stands for «Perl 6». The program should really be renamed to «rakudoc».

If the formatting is less than awesome, read the documentation online instead: github.com/hoelzro/p6-linenoise.

The «Basic History» Section:

use Linenoise;

my constant HIST_FILE = '.myhist';
my constant HIST_LEN  = 10;

linenoiseHistoryLoad(HIST_FILE);
linenoiseHistorySetMaxLen(HIST_LEN);

while (my $line = linenoise '> ').defined {
    linenoiseHistoryAdd($line);
    say "got a line: $line";
}

linenoiseHistorySave(HIST_FILE);

We can apply this to our shell like this. And note that the gather/take we added in the «rash-gather» version had to go away:

use Linenoise;

constant HIST_FILE =  ( $*HOME.add: '.rash-hist' ).Str;
constant HIST_LEN  = 25;

linenoiseHistoryLoad(HIST_FILE);
linenoiseHistorySetMaxLen(HIST_LEN);

This is mostly copy and paste, except that we store the history file («.rash-hist») in the user's home directory.

The dynamic variable $*HOME gives us the home directory, as an IO object. We attach the history file name with the IO.add method (that handles the directory separator for us, so that we can avoid that problem). We then apply .Str on it to get a string.

Remember to convert things that are not string to strings, as done here with an IO object. The «Linenoise» library is written in C, and sending off objects to C code expecting string values can lead to interesting errors.

See docs.raku.org/language/variables#index-entry-$*HOME for more information about $*HOME.

See docs.raku.org/routine/add for more information about IO.add.

The loop looks like this:

while (my $line = linenoise '> ').defined
{
  linenoiseHistoryAdd($line);

  given $line
  {
    ...
  }
}

Note that «Linenoise» catches <Control-C>, but the value is undefined in that case. We use .defined on the loop to drop out when <Control-C>is used.

Also note that «linenoiseHistoryAdd» ignores empty lines, and will not add the command if it is exactly the same as the previous one.

All that remains is saving the history file:

linenoiseHistorySave(HIST_FILE);

Command Line Completion

Let us add command completion while we are at it, as it is described in the «Linenoise» documentation as well.

The «Tab Completion» Section:

use Linenoise;

my @commands = <help quit list get set>;

linenoiseSetCompletionCallback(-> $line, $c {
    my ( $prefix, $last-word ) = find-last-word($line);

    for @commands.grep(/^ "$last-word" /).sort -> $cmd {
        linenoiseAddCompletion($c, $prefix ~ $cmd);
    }
});

while (my $line = linenoise '> ').defined {
    say "got a line: $line";
}

The procedure «find-last-word» isn't defined anywhere, but we can get away with a simpler version expanding the whole command.

We add this simplified callback after the line defining @commands:

linenoiseSetCompletionCallback(-> $line, $c
{
  for @commands.grep(/^ "$line" /).sort -> $cmd
  {
    linenoiseAddCompletion($c, $cmd);
  }
});

The full program looks like this:

File: rash-tab
use Linenoise;

constant HIST_FILE = ( $*HOME.add: '.rash-hist' ).Str;
constant HIST_LEN  = 25;

linenoiseHistoryLoad(HIST_FILE);
linenoiseHistorySetMaxLen(HIST_LEN);

my @commands = <exit help version>;

linenoiseSetCompletionCallback(-> $line, $c
{
  for @commands.grep(/^ $line /).sort -> $cmd
  {
    linenoiseAddCompletion($c, $cmd);
  }
});

say 'rash: Enter «exit» to exit';

while (my $line = linenoise '> ').defined
{
  linenoiseHistoryAdd($line);

  given $line
  {
    when "exit"    { last; }
    when "help"    { say "Legal commands: { @commands }" }
    when "version" { say "Version 0.05"; }
    default        { say "Unknown command: \"$_\" (use \"help\" for a list of commands)";  }
  }
}

linenoiseHistorySave(HIST_FILE);

It isn't very useful yet, but try it out.

A history command could have been nice, but «Linenoise» doesn't support it.

Part 3: The Execution

See the next part; Part 3: The Execution.