Rash - A Raku Shell

Part 5: The Dynamic

by Arne Sommer

Rash - Part 5: The Dynamic

[67.5] Published 13. April 2020.

See also: The Introduction | Part 1: The Path | Part 2: The Loop | Part 3: The Execution | Part 4: The Interrupt.

Dynamic Shell

Our shell doesn't cope with programs added after it has been started (and has traversed the path). There are several ways this can be fixed:

• Dynamic Lookup

If the program isn't in our list, update the list of programs and see if that does the trick.

The downside is that typing a non-existing program causes a lot of file system access.

• Rehash Command

Add an explicit «rehash» command that does a manual update of the list of programs.

This is easy, just add this line to the gather block:

when "rehash" { %programs = (); do-rehash; }

Update the list of commands:

%commands{$_} = True for <cd exit help pwd rehash version which6>;

The full program is available as «raku-rehash».

Aliases

The alias keyword can be used to create real aliases. We can reuse the already written - and discarded - code for handling a configuration file (and the «allow» keyword).

We start with adding code to add aliases, and list them (with the same command), «alias»:

my %aliases;

multi sub do-alias ($command)
{
  my ($alias, $full) = $command.split(/\s/, 2); 

  %aliases{$alias} = $full;
}

multi sub do-alias ()
{
  say "$_ -> %aliases{$_}" for %aliases.keys.sort; 
}

And these lines in the given block:

when "alias"         { do-alias; }
when /^alias\s+(.*)/ { do-alias $0; }

Update the list of commands:

%commands{$_} = True for <alias cd exit help pwd rehash version which6>;

Using the aliases doesn't work yet. Adding these lines just above the given block fixes that:

my $cmd = $line.words[0];                                  # [1]
$line.= subst(/^$cmd/, %aliases{$cmd}) if %aliases{$cmd};  # [2]

[1] Get the first word of the command.

[2] If it is an alias (e.g. «e») expand it to the full name (e.g. «emacs»).

And we are good to go.

The full program is available as «rash-alias».

We can test it:

$ raku rash-alias
rash: Enter «exit» to exit
> alias e emacs
> alias m more
> alias
e -> emacs
m -> more

Run some of them, and check that command completion works as well.

A User Configuration File

Note that commands added with «alias» are not recognized by the «Linenoise» command completion. We can fix that, partially, by adding support for a configuration file with aliases.

We had support for a configuration file several version ago. «rash-commands» was the last one that had it. So we copy the code from there.

Add this line just above «do-rehash»:

read-conf;

Add back this line:

constant CONF_FILE = ( $*HOME.add: '.rashrc' ).Str;

Then we add the list of aliases to the «Linenoise» callback:

my @all = (|%commands.keys, |%programs.keys, |%aliases.keys);

Note that this adds aliases from the configuration file to the list of commands recognized by «Linenoise». Aliases added manually (on the command line) are not.

Copy the old «read-conf» and change it:

sub read-conf
{
  return unless CONF_FILE.IO.r;
  
  for CONF_FILE.IO.lines
  {
    when /^alias\s+(.*)/ { do-alias $0.Str }
  }
}

Then update the configuration file with something like this:

File: ~/.rashrc
alias e emacs 
alias m more
alias ll ls -l
alias t top

Check that it works:

$ raku rash-alias2
rash: Enter «exit» to exit
> alias
e -> emacs 
ll -> ls -l
m -> more
t -> top

The full program looks like this:

File: rash-alias2
use Linenoise;

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

linenoiseHistoryLoad(HIST_FILE);
linenoiseHistorySetMaxLen(HIST_LEN);

my %commands; %commands{$_} = True for <alias cd exit help pwd rehash version which6>;
my %programs;
my %aliases;

read-conf;
do-rehash;

linenoiseSetCompletionCallback(-> $line, $c
{
  my @all = (|%commands.keys, |%programs.keys, |%aliases.keys);

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

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

signal(SIGINT).tap();

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

  my $cmd = $line.words[0];
  $line.= subst(/^$cmd/, %aliases{$cmd}) if %aliases{$cmd};

  given $line
  {
    when "alias"           { do-alias; }
    when /^alias\s+(.*)/   { do-alias $0; }
    when /^cd\s+(\S+)/     { do-chdir $0; }
    when "exit"            { last; }
    when "help"            { say "Built-in commands: { %commands.keys.sort }" }
    when "pwd"             { say $*CWD.Str; }
    when "rehash"          { %programs = (); do-rehash; }
    when "version"         { say "Version 0.17"; }
    when /^which6\s+(\S+)/ { do-which $0; }
    default
    {
      my ($cmd, @args) = $line.words;
      %programs{$cmd}
        ?? do-run %programs{$cmd}, @args
        !! say "Unknown command: \"$_\" (use \"help\" for a list of commands)";
    }
  }
}

linenoiseHistorySave(HIST_FILE);

sub do-run ($cmd, @args)
{
  my $res = @args
    ?? run $cmd, @args
    !! run $cmd;

  if ! $res.pid
  {
    say "$cmd: command not found";
  }
  elsif $res.exitcode 
  {
    say "$cmd: exit with code { $res.exitcode }";
  }
}

sub do-chdir ($dir)
{
  say "cd: $dir: No such file or directory" unless chdir $dir;
}

sub do-rehash
{
  for %*ENV.split(":") -> $dir
  {
    next unless $dir.IO.d;
    %programs{.Str} = "$dir/"  ~ .Str for indir($dir, { dir(:x) }).sort
  }
}

sub do-which ($command)
{
  if %commands{$command}
  {
    say "$command is a rash built-in command.";
  }
  else
  {
    say %programs{$command} if %programs{$command};
  }
}

multi sub do-alias ($command)
{
  my ($alias, $full) = $command.split(/\s/, 2); 

  %aliases{$alias} = $full;
}

multi sub do-alias ()
{
  say "$_ -> %aliases{$_}" for %aliases.keys.sort; 
}

sub read-conf
{
  return unless CONF_FILE.IO.r;
  
  for CONF_FILE.IO.lines
  {
    when /^alias\s+(.*)/ { do-alias $0.Str }
  }
}

Exercise 3: Safe Alias

Make it illegal to add an alias with the same name as a built-in command:

$ raku rash-safe
> alias version /usr/bin/gnats
Unable to redefine internal command: version

See The solution.

Exercise 4: Removing Aliases

It isn't possible to remove an alias, only replace it with a new definition. Fix this with a new «unalias» command. It takes one argument, which is the alias to remove.

Tip: Use the :exist adverb to check if a key is present in a hash, and the :delete adverb to remove it (the key/value pair). See docs.perl6.org/language/subscripts#:exists and docs.perl6.org/language/subscripts#index-entry-:delete_(subscript_adverb) for details.

Note that removing an alias that came from the configuration file leaves the name in the Linenoise command completion list. Fix this by setting up the callback again after removal or addition of an alias.

See The solution.

Part 6: The Process

See the next part; Part 6: The Process.