3
votes

I wrote simple command that lets me run the last N commands from terminal history. It looks like this: $> r 3 which will replay the last 3 commands.

I have the following alias in my bash profile: alias r="history -w; runlast $1"

And then the following simple perl script for the runlast command:

#!/usr/bin/env perl
use strict;
use warnings;

my $lines = $ARGV[0] || exit;

my @last_commands = split /\n/, 
    `bash -ic 'set -o history; history' | tail -$lines`;

@last_commands = 
    grep { $_ !~ /(^r |^history |^rm )/ } 
    map { local $_ = $_; s/^\s+\d+\s+//; $_ } 
    @last_commands;

foreach my $cmd (@last_commands) {
  system("$cmd");
}

This works but my bash profile has aliases and other features (e.g. color output) I want the perl script to have access to. How do I load the bash profile for perl so it runs the bash commands with my bash profile? I read somewhere that if you "source the bash profile" for perl you can get it to work. So I tried adding source ~/.bash_profile; to my r command alias but that didn't have an effect. I'm not sure if I was doing that correctly, though.

3
Its generally a bad idea to use aliases in a script because that means the behaviour can vary depending on where it is run, which is a support nightmare. It is better to use functions instead.cdarke
$1 in the alias definition doesn't mean what you hope it means. Anyway, the only thing you really need to know about aliases is, use functions instead.tripleee

3 Answers

3
votes

The system forks a process in which it runs a shell, which is non-login and non-interactive; so no initialization is done and you get no aliases. Also note that the shell used is /bin/sh, which is generally a link to another shell. This is often bash but not always, so run bash explicitly.

To circumvent this you need to source the file with aliases, but as bash man page says

Aliases are not expanded when the shell is not interactive, unless the expand_aliases shell option is set using shopt (see the description of shopt under SHELL BUILTIN COMMANDS below).

Thus you need shopt -s expand_aliases, as mentioned. But there is another screw: on that same physical line aliases are not yet available; so it won't work like this in a one-liner.

I'd also recommend to put aliases in .bashrc, or in a separate file that is sourced.

Solutions

  • Add shopt -s expand_aliases to your ~/.bashrc, and before the aliases are defined (or the file with them sourced), and run bash as a login shell

    system('/bin/bash', '-cl', 'source ~/.bashrc; command');
    

    where -l is short for --login.

    In my tests the source ~/.bashrc wasn't needed; however, the man page says

    When bash is invoked as an interactive login shell, or as a non-interactive shell with the --login option, it first reads and executes commands from the file /etc/profile, if that file exists. After reading that file, it looks for ~/.bash_profile, ~/.bash_login, and ~/.profile, in that order, and reads and executes commands from the first one that exists and is readable.

    and goes on to specify that ~/.bashrc is read when an interactive shel that is not login runs. So I added explicit sourcing.

    In my tests sourcing .bashrc (with shopt added) while not running as a login shell didn't work, and I am not sure why.

    This is a little heavy-handed. Also, initialization may be undesirable to run from a script.

  • Source ~/.bashrc and issue shopt command, and then a newline before the command

    system('/bin/bash', '-c', 
        'source ~/.bashrc; shopt -s expand_aliases\ncommand');
    

    Really. It works.

Finally, is this necessary? It asks for trouble, and there is probably a better design.

Other comments

  • The backticks (qx) is context-aware. If it's used in list context – its return assigned to an array, for example – then the command's output is returned as a list of lines. When you use it as the argument for split then it is in the scalar context though, when all output is returned in one string. Just drop split

    my @last_commands = `bash -ic 'set -o history; history $lines`;
    

    where I also use history N to get last N lines. In this case the newlines stay.

  • history N returns last N lines of history so there is no need to pipe to last

  • Regex substitution in a map can be done without changing the original

    map { s/^\s+\d+\s+//r } @last_commands;
    

    With /r modifier the s/// operator returns the new string, not changing the original. This "non-destructive substitution" has been available since v5.14

  • No need to explicitly use $_ in the last grep, and no need for parenthesis in regex

    grep { not /^r |^history |^rm ?/ } ...
    

    or

    grep { not /^(?:r|history|rm)[ ]?/ } ...
    

    where parens are now needed, but as it is only for grouping the ?: makes it not capture the match. I use [ ] to emphasize that that space is intended; this is not necessary.

    I also added ? to make space optional since history (and r?) may have no space.

2
votes

The proper solution is to have your Perl script just print the commands, and make your current interactive shell eval the string printed from your history. (I would probably get rid of Perl entirely but that's beside the point here.)

If the commands get evaluated in the current shell, you avoid many contextual problems which would be very hard or even intractable with system() or generally anything involving a new process. For example, a subprocess cannot have access to non-exported variables in the current shell. var="foo", echo "$var"; r 1 is going to be very hard to solve correctly with your current approach. Using the current interactive shell will also naturally and easily solve the problems you were having with trying to get a noninteractive subshell act like an interactive one.

Aliases suck anyway, so let's redefine r as a function:

r(){
    history -w
    eval $(printlast "$1")
}

... where refactoring runlast into a different script printlast is a trivial additional requirement. Or maybe just turn it into a (much simpler!) shell function:

printlast () {
    history "$1" |
    perl -ne 's/^\s*\d+\s+\*?//; print unless m/^(history|rm?)($|\s)'
}

With this, you can also get rid of history -w from the r definition.

Notice how we are using Perl where it is useful; but the main functionality makes sense to keep in the shell when you're dealing with the shell.

1
votes

You can't source in a Bash script into a Perl script. The bash_profile has to be sourced in by the shell that executes the command. When Perl runs system, it forks a new shell each time.

You have to source in the bash_profile for each command that you run through system:

system('source ~/.bash_profile; ' + $cmd);

One more thing, system invokes a non-interactive shell. So, your Bash aliases defined in .bash_profile won't work unless you invoke:

shopt -s expand_aliases

inside that script