Sunday, April 4, 2010

Making Bash Error Messages Friendlier

Trapping and Befriending Error Messages


The command line! The bane of the novice Linux user! It's so useful -- yet it can be challenging to learn.


The error messages don't help much. "Command not found." "Permission denied." As a newbie, you need to know more.


Isn't that the right command? Why was permission denied? How are you to figure out what the real problem was? And why can't the shell help you with that?


Ubuntu has taken some steps in that direction already. They've set up the bash shell so that if you get "Command not found", most of the time you'll also see suggestions on what you might have meant: commands that are spelled similarly, or commands that aren't installed along with which package you need to install to get them.


It looks like this:


$ catt /etc/fstab
No command 'catt' found, did you mean:
 Command 'cat' from package 'coreutils' (main)
 Command 'cant' from package 'swap-cwm' (universe)
catt: command not found


It's an excellent step. Perhaps still not 100% clear -- you still need to know what those packages are and how to install them -- but it's a good start!


But what about other errors, like the all too common "Permission denied"? Ubuntu's error handling uses a function built into bash for that specific purpose, a function called command_not_found_handle that can't be used for other types of errors.


Happily, bash has a more general error trapping mechanism that you can use to handle any kind of error the user might make.


The key is bash's trap command. First you define an error handler function:

function err_handle {
  echo "Place your error handling code here"
}


Then use trap to tell the shell to call your error handler any time it gets an error:

trap 'err_handle' ERR


In the error handler, you can check the shell variable $? to find out what the error code was. So the first step is to figure out which errors you need to catch.


How do you do that? The easiest way is to write a very simple error handler that just prints the numeric code.

function err_handle {
  status=$?
  echo status was $status
}
trap 'err_handle' ERR


Then try typing some commands you know are wrong. For instance, you might misspell the command name:

$ catt foo.html
catt: command not found
status was 127


Or suppose you type the name of an existing file instead of a command:

$ /etc/passwd
bash: /etc/passwd: Permission denied
status was 126


Clearly 126 and 127 are two cases worth handling. There's one other case that's easy for anybody, not just beginners, to hit: a typo in a filename.

$ ls bogusfile
/bin/ls: cannot access bogusfile: No such file or directory
status was 2


So let's catch error 2 as well as 126 and 127. If you have other errors you or people you know tend to hit frequently, you can find out their error codes the same way.


Now you can check in your error handler to make sure you're only handling the types of errors you're prepared to catch:

function err_handle {
  status=$?
  echo status was $status

  if [[ $status -ne 2 && $status -ne 126 && $status -ne 127 ]]; then
    return
  fi


The next problem is to get the exact line the user typed. You'd think that would be easy, but it's the hardest part of the whole endeavor: bash doesn't give you a good way to do it.


Here's the best I've found, with help from a number of bash hackers:

  # Get the last typed command.
  lastcmd=$(history | tail -1 | sed 's/^ *[0-9]* *//')


Then split the line into the command (the first word) and arguments (everything else). You can use bash's read command for that:

  read cmd args <<< "$lastcmd"


Now you know the error code and the command. The rest is just a matter of figuring out what sorts of errors your users are likely to hit, then offering them useful suggestions in each case.



For instance, if the user typed the name of a file instead of an executable program, wouldn't it be handy to check the type of the file and suggest programs they might have intended? Something like this:

$ /tmp
bash: /tmp: is a directory
status was 126
Perhaps you meant: cd /tmp

$ schedule.html
bash: ./schedule.html: Permission denied

schedule.html is an HTML file. Did you want to run: firefox schedule.html

That's easy to do. You 
can check for a directory with the shell construct
if [[ -d. If it's a file, you can use the file
command to guess what type it is.


  if [[ -e $cmd ]]; then
    if [[ -d $cmd ]]; then
      echo "Perhaps you meant: cd $cmd"

    elif [[ ! -x $cmd ]]; then
      echo ""
      filetype=$(file $cmd)
    
      # HTML must come before text, since file says "HTML document text"
      if  [[ $filetype = *HTML* ]]; then
        echo "$cmd is an HTML file. Did you want to run: firefox $cmd"
      [ ... ]    


You can use similar methods for each file type you want to handle -- you might want to suggest apps the user could call for image files, text files, movies and so on.



What about that case of a filename missing a slash? That's easy to check for too: look through the list of arguments, check whether each file exists, and if it doesn't, see if adding a slash to the beginning gives you the name of an existing file:



  if [[ $status -eq 2 ]]; then
    # loop over args looking for the first one that might be missing a slash
    for f in $cmd "${args[@]}"; do
      if [[ $f = */* && ! -e $f ]]; then
        if [[ -e /$f ]]; then
          echo ""
          echo "$f doesn't exist, but /$f does."
          echo "Did you forget a leading slash?"
          return
        fi
      fi
    done
You can even check for cases where the file isn't readable, and suggest that the user might need to be root.
  if [[ -e $cmd ]]; then
    if [[ -d $cmd ]]; then
      echo "Perhaps you meant: cd $cmd"
    elif [[ ! -x $cmd ]]; then
      if [[ ! -r $cmd ]]; then
        echo ""
        echo "$cmd is a file but it's not readable or executable."
        echo "Maybe you need to be root?"


A sample error-handling script
Here's a basic example of a bash error handler. You can add this to the end of your .bashrc; or save it as a separate file, like ~/.bash-error, then add this line to your .bashrc:


. $HOME/.bash-errs


Here's the script:


#
# Offer slightly friendlier error messages for certain types of errors.
#
function err_handle {
  status=$?

  if [[ $status -ne 2 && $status -ne 126 && $status -ne 127 ]]; then
    return
  fi

  # Ucky pipeline which is, amazingly enough,
  # the only way to get the last typed command from bash.
  # fc  -n -l -1 doesn't always have the command yet,
  # !! doesn't work from inside functions
  # and BASH_COMMAND gets confused by functions like ls().
  lastcmd=$(history | tail -1 | sed 's/^ *[0-9]* *//')

  # cool way to split a string into component words:
  read cmd args <<< "$lastcmd"

  # Handle possible errors involving forgetting a leading slash if the
  # command was okay but the error was 2, "no such file or directory".
  if [[ $status -eq 2 ]]; then
    # loop over args looking for the first one that might be missing a slash
    for f in $cmd "${args[@]}"; do
      if [[ $f = */* && ! -e $f ]]; then
        if [[ -e /$f ]]; then
          echo ""
          echo "$f doesn't exist, but /$f does."
          echo "Did you forget a leading slash?"
          return
        fi
      fi
    done
    return
  fi

  if [[ -e $cmd ]]; then
    if [[ -d $cmd ]]; then
      echo "Perhaps you meant: cd $cmd"
    elif [[ ! -x $cmd ]]; then
      if [[ ! -r $cmd ]]; then
        echo ""
        echo "$cmd is a file but it's not readable or executable."
        echo "Maybe you need to be root?"
        echo "You could try sudo less $cmd"
        echo "or sudo $(myeditor) $cmd"
        return 127
      fi

      #
      # By now, we know it's a file and it's readable.
      # Figure out the file's type, and print appropriate messages:
      #
      echo ""
      filetype=$(file $cmd)
    
      # HTML must come before text, since file says "HTML document text"
      if  [[ $filetype = *HTML* ]]; then
        echo "$cmd is an HTML file. Did you want to run: firefox $cmd"
    
      elif  [[ $filetype = *text* ]]; then
        echo "$cmd is a text file. Did you want to run:"
        echo "    less $cmd"
        echo "    vim $cmd"
    
      elif  [[ $filetype = *image* ]]; then
        echo "$cmd is an image file. Did you want to run:"
        echo "    pho $cmd"
        echo "    gimp $cmd"

      else
        # "file" gives terribly complex output for MS Office documents
        # so get the mime type to detect those:
        mimetype=$(xdg-mime query filetype $cmd | sed 's/;.*$//')
        if [[ $mimetype == application/msword ]]; then
          echo "$cmd is a Microsoft Word file."
          echo "Perhaps run: ooffice $cmd"
        elif [[ $mimetype =~ application/.*ms- ]]; then
          echo "$cmd is a file of type"
          echo "  $mimetype (Microsoft)."
          echo "Perhaps try: ooffice $cmd"

        else
          #
          # Unknown file type -- bomb out.
          #
          echo "$cmd is a file of type $mimetype."
          echo "What do you want to do with it?"
        fi
      fi

    else
      echo "Hmm, $cmd exists and is executable -- not sure what went wrong"
    fi
#  else
#    echo "Sorry, $cmd doesn't exist"
    # If we want to be REALLY nice we could look for similarly named progs.
    # But it turns out Ubuntu's command-not-found-handle does that already.
  fi
}

# Trap errors.
trap 'err_handle' ERR


No comments:

Post a Comment

Post a Comment