Correct invalid command dynamically, without aliasing

2

I'm using GNU bash version 4.3.46 .

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

cd..
gitlog

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

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

But since my mistake of forgetting the space is frequent, I would like a more general solution, instead of having to create dozens of alias for each possibility - since the problem also happens with commands that I only use at times, and it's not worth creating a alias just for this.

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 to test (first I wanted to test a command with space just to see how it works; a second step is to adapt it to check cases with no space):

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

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

Output was log , which is correct. There are still some more details to work on in the script, such as calling the command, if there is only one possibility, etc. But that's beside the point.

The focus of the question are the problems I can not solve:

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

In other words, if I type cd.. , bash will recognize that this command is invalid and should call this script (which will correct for cd .. and will run the corrected command).

How to do this? (if possible)

I'm also starting to think that maybe this script is not the best way, but I do not know if there's another way to solve it.

    
asked by anonymous 21.05.2018 / 14:44

2 answers

2

I found out that from Bash 4 there is Command not found handler , which is a predefined function that is called when a command does not exist. And the best thing is that you can override this function ( command_not_found_handle ).

But first I had to solve the problem of a command being 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 opted 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 the above command, ${PATH//:/ } is the PATH directories, but replacing : with space, so that find uses this list of directories to search.

I look for the files ( -type f ) that I'm allowed to execute ( -perm -u+x ). I then use awk and cut to delete the full path (as /usr/bin/comando ) and only get the filename ( comando ).

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

Having this list, I throw it into a variable and loop through the results. For each command name in the list, I see if what I typed starts with the command name, and I make the 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 $1 variable.

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

Within if , I run the command ( ${c} ), and break the original string ( gitlog ), using string emulation syntax: ${1#${c}} removes the occurrence of ${c} (that is, git ) of 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] .

At the end, I return $? , which is the exit status of the last executed command .

If no command is found, I should return the default exit status 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 is:

# 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 my .bashrc and it works fine.

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

The cd.. case does not work because of this small detail . So this is the only one I left as% with% same. But the other commands work normally.

PS: Replacement commands ( alias and ${1#${c}} ) I got this link .

    
22.05.2018 / 19:38
3

I do not know if it is possible to do exactly what you want, but you can use the trap command to capture the DEBUG signal triggered by each command you execute,

Configuring trap :

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

Removing 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 an auxiliary script that will be executed by getting 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 look something like:

#!/bin/bash

case "$1" in

    'cd..')
    ;;

    'c d . .')
    ;;

    'c d..')
    ;;

esac

Reference: link

    
22.05.2018 / 13:17