Fix invalid command dynamically, no alias required

Asked

Viewed 685 times

3

I’m using GNU bash version 4.3.46.

One problem I have when typing commands, is that I often end up forgetting a space between the command and its parameters. Examples:

cd..
gitlog

When the right should be, respectively, cd .. and git log. The most common cases (commands I use most often) I solved by creating several alias, as an example:

alias cd..='cd ..'
alias gitlog='git log'

But since my mistake of forgetting space is frequent, I would like a more general solution, rather than having to create dozens of alias for every possibility - since the problem also happens with commands that I only use sometimes, and it is not worth creating a alias just for that.


First I tried to make a script that, given an incomplete command, shows the options to complete it. In the example below I used git l as input, just for testing (first I wanted to test a command with space just to see how it works; a second step is to adapt it to check the cases without space):

__print_completions() {
    printf '%s\n' "${COMPREPLY[@]}"
}

COMP_WORDS=(git l)
COMP_LINE='git l'
COMP_POINT=6
COMP_CWORD=1
_git
__print_completions

The exit was log, what is right. There are still a few more details to work on the script, such as calling the command if there is only one possibility, etc. But this is not the case.

The focus of the question is the problems I’m failing to solve:

  • how to make this script receive as parameter the command I typed?
  • how to break this command correctly?
    • ex: cd.. can be broken as c d.., cd .. and cd. .
  • how to make the script be triggered only when the command I typed is not found? (i.e., if there is an alias or a valid command, I don’t need this script, just run the command)

That is to say, if I type cd.., bash will recognize that this command is invalid and must call this script (which will fix to cd .. and run the corrected command).

How to do this? (if possible)


I’m also starting to think that maybe this script isn’t the best way, but I don’t know if there’s any other way to solve this.

2 answers

3


Eventually I discovered that from Bash 4 there is the Command not found Handler, which is a predefined function that is called when a command does not exist. And best of all, you can override this function (command_not_found_handle).

But first I had to solve the problem that a command could be broken in invalid ways. For example, gitlog can be broken in several ways:

g itlog
gi tlog
git log <- única opção válida
gitl og
gitlo g

So I chose to have a list of all valid commands, and from there I use these names to avoid an invalid break:

find ${PATH//:/ } -type f -perm -u+x 2>/dev/null |awk -F"/" '{print $NF}'|cut -d '.' -f 1|sort|uniq

In command above, ${PATH//:/ } are the directories of the PATH, but replacing the : by space, so that the find use this directory list to search.

I search the archives (-type f) that I’m allowed to execute (-perm -u+x). Then use awk and cut to eliminate the complete path (such as /usr/bin/comando) and keep only the file name (comando).

Then I use sort and uniq to delete repeated names. I know that with this I am ignoring files with equal names in different folders, but for now this has not shown a problem.

Having this list, I throw it into a variable and loop it by the results. For each command name on the list, I see if what I typed starts with the command name, and I break:

# sobrescrever a função para tratar comando não encontrado
command_not_found_handle() {
    CMDS=`find ${PATH//:/ } -type f -perm -u+x 2>/dev/null |awk -F"/" '{print $NF}'|cut -d '.' -f 1|sort|uniq`
    # para cada comando
    for c in $CMDS
    do
        # se o que eu digitei começa com o nome do comando
        if [[ $1 == ${c}* ]]; then
            # roda o comando
            ${c} "${1#${c}}" "${@:2}"
            return $?
        fi
    done
}

For example, if I typed gitlog, this is a command that does not exist, and this is passed to the function, in the variable $1.

In the for I see the list of commands, and when c is equal to git, will enter the if [[ $1 == ${c}* ]] (the asterisk is the trick to compare if $1 begins with git).

Within the if, I spin the command (${c}), and break the original string (gitlog), using the syntax of Removal string: ${1#${c}} removes the occurrence of ${c} (that is to say, git) of the variable $1 (gitlog), then the result of this expression is log.

Then I pass the other parameters of the command, if they exist (${@:2}). So if I type gitlog [parâmetros], the final command will be git log [parâmetros].

In the end, I return $?, which is the Exit status of the last executed command.

If no command is found, I must return the Exit status standard for command not found, which is 127. So I added another return at the end, after the loop, if it does not find any commands. I also print a message to simulate the same bash behavior when a command is not found.

The final code of the function was:

# sobrescrever a função para tratar comando não encontrado
command_not_found_handle() {
    CMDS=`find ${PATH//:/ } -type f -perm -u+x 2>/dev/null |awk -F"/" '{print $NF}'|cut -d '.' -f 1|sort|uniq`
    # para cada comando
    for c in $CMDS
    do
        # se o que eu digitei começa com o nome do comando
        if [[ $1 == ${c}* ]]; then
            # roda o comando
            ${c} "${1#${c}}" "${@:2}"
            return $?
        fi
    done

    # comando não encontrado, imprimir mensagem e retornar exit status
    printf 'bash: %s: command not found\n' "$1" >&2
    return 127
}

I put this function in mine .bashrc and is working well.

Sometimes it takes a second or two, maybe because making a find in the PATH is not the fastest thing in the world (maybe I shouldn’t do this search all the time).

The case of cd.. doesn’t work because of this small detail. So this is the one I left as alias same. But other commands work normally.


PS: The replacement controls (${1#${c}} and ${PATH//:/ }) I took it at this link.

  • 1

    Very interesting!

2

I don’t know if it’s possible to do exactly what you want, but you can use the command trap to capture the signal from DEBUG fired by each command you execute, let’s see:

Configuring the trap:

$ trap 'echo -e "Capturei o comando: $BASH_COMAND"' DEBUG

Removing the trap:

$ trap - DEBUG

Example:

$ trap 'echo -e "Capturei o comando: $BASH_COMMAND"' DEBUG
Capturei o comando: __vte_prompt_command

$ c d . . 
Capturei o comando: c d . .
bash: c: command not found...
Capturei o comando: __vte_prompt_command

$ trap - DEBUG
Capturei o comando: trap - DEBUG

You can create a script helper that will be executed by receiving the variable with the executed command ($BASH_COMMAND) as an argument, for example:

$ trap './foobar.sh $BASH_COMAND' DEBUG

The implementation of foobar.sh would be something like:

#!/bin/bash

case "$1" in

    'cd..')
    ;;

    'c d . .')
    ;;

    'c d..')
    ;;

esac

Reference: https://superuser.com/questions/175799/does-bash-have-a-hook-that-is-run-before-executing-a-command

  • 1

    In the end I found another solution (see my answer). Anyway, thank you for your reply as I learned a new command (trap).

Browser other questions tagged

You are not signed in. Login or sign up in order to post.