Skip to content

Sharing some prompt patches #8

Open
@emanuele6

Description

Hi, bud.

Recently, I have been thinking of remaking my prompt from scratch to add some features I would like to have.
I have been running a patched version of your prompt for a while (more than 2 years, lol).
I thought I'd share my patches before I stop using it since there are a few issues with the vanilla version.

I'll describe the main patches I have added:

patch1:

This is the first thing I changed (less than a day after I started using it).
The DEBUG trap is messing with $_ which I find extremely annoying, the fix is to add a "$_" at the end of the command in the trap.

# trap ': "${_t_prompt:=$(prompt_timestamp)}"' DEBUG
trap ': "${_t_prompt:=$(prompt_timestamp)}" "$_"' DEBUG

patch2:

var=$(printf) creates a subshell for no reason, bash's printf -v is more clean.

# ms="$(printf '%03d' $ms)"
printf -v ms '%03d' "$ms"

patch3:

This is the biggest one, I basically redid the whole ps generation code.

case $PWD in
    "$_last_prompt_path") ps=$_last_prompt_string ;;
    "$HOME")              ps='~'                  ;;
    /)                    ps=/                    ;;
    *)
        < <(printf '%s/\0' "${PWD/#"$HOME/"/'~/'}") \
            IFS=/ read -rd '' -a path_dirs
        for d in "${path_dirs[@]}"; do
            if [[ $d =~ [[:alnum:]] || $d =~ [[:print:]] || -z $d ]]
                then             : "${BASH_REMATCH[0]}"
                else d=${d:0:1}; : "${d@Q}"
            fi
            ps+=$_/
        done
        ps=${ps%/} ps=${ps//\\/\\\\} ps=${ps//\`/\\\`}
        ;;
esac
budlabs repo version (this was not the final version iirc, we had modified it to make it use shortpwd='\w' and ${shortpwd@P}, but that version was also problematic for the same reasons I will describe.)

if [[ $_last_promptdir = "$PWD" ]]; then
  ps="$_last_pathstring"
else
  # set IFS to forward slash to conveniently
  # loop path, make it local so we don't replace
  # the shells main IFS
  local IFS='/'

  # replace homde dir with literal ~ in PWD loop
  # the path and use the first alpha/numeric
  # character OR the first character of each
  # directory.
  for d in ${PWD/~/'~'}; do
    [[ $d =~ [[:alnum:]] ]]         \
      && ps+="${BASH_REMATCH[0]}/"  \
      || ps+="${d:0:1}/"
  done

  # remove trailing / if we are not in root dir
  [[ ${ps} = / ]] || {
    ps="${ps%/}"

    # expand the last directory
    # [[ $ps != '~' ]] && ps="${ps%/*}/$d"
  }

  unset d

  # these variables are global
  _last_promptdir="$PWD"
  _last_pathstring="$ps"
fi

First of all, the unset d will unset the d variable every time you change directory which can be very annoying and sneaky. having d as a local variable is much better.

I have replaced the if with a case and added a shortcut for $HOME and /.

Iterating over unquoted ${shortpwd@P} is a bad idea. If that string contains *, ? or other special characters pathname expansion will occur and potentially mess with our plans. (and the prompt may stop working if you have shopt -s nullglob.)
You could solve this using set -f (to disable pathname expansion) before the for loop and then using set +f after to re-enable it, but you could be playing with bash in your shell and intentionally enable -f and whenever you change directory this will re-disable it which is, once again, annoying.
Localising set options in bash is do-able, but messy and using this set -f feels dirty.

A much cleaner solution is to just read an array like so:

IFS=/ read -rd '' -a path_dirs < <(printf '%s/\0' "${PWD/#"$HOME/"/'~/'}")

Now I can simply use "${PWD/#"$HOME/"/'~/'}" to replace "$HOME/" at the beginning of $PWD with ~/ which is much simplier than ${@P} since we don't have to deal with "$PWD" == "$HOME".

The reason I use -d '' and \0 is to make bash choose the correct characters even if the directory contains newline characters. (another issue of the prompt was that it was completely broken or multiline in some very edge case directory.)

I use %s/\0 instead of just %s\0 for correctness (see here: read -ra ignores one IFS from the end of the input if the input ends in one or more IFS) even if it should not be necessary in this case.

Now I can safely iterate over the array without having to deal with word splitting, pathname expansion, set -f and other shell shenanigans.

patch3.5:

In the loop, I have replaced the old:

#if [[ $d =~ [[:alnum:]] ]]
#    then ps+=${BASH_REMATCH[0]}/
#    else ps+=${d:0:1}/
#fi

With:

if [[ $d =~ [[:alnum:]] || $d =~ [[:print:]] || -z $d ]]
    then             : "${BASH_REMATCH[0]}"
    else d=${d:0:1}; : "${d@Q}"
fi
ps+=$_/

I use $_ just to reduce code duplication, it is not too important.

If the directory name is /etc/$'\t?'/a I would rather have /e/?/a than /e/$'\t'/a, but I would still like to have /e/b/a for /etc/?b/a, so I've added =~ [[:print:]] after an || to match printable characters if there are no alphanumeric characters.

In the else branch now only appear the characters that are not printable so I use ${@Q} to make them visible:

# if PWD is:
pwd='/usr/
/hi'
# then ps will be: /u/$'\n'/h
# which I think is nicer than:
ps='/u/
/h'
# this also makes the prompt usable in the extremely corner case
# situation in which the dirname only has non-printable character that
# are not TAB or LF.

I added -z $d such that /etc/profile.d/ is converted to /e/p and not ''/e/p because of ${@Q}. Also, this covers the (impossible on ext4 and any other filesystem I know of, but whatever) case in which a directory name is the empty string and makes /usr/local/share//dir, /u/l/s//d instead of /u/l/s/''/d.
NOTE: when -z $d is true, BASH_REMATCH[0] is set to nothing by the previous =~ [[:print:]].

patch4:

This is quite important. `cmd`, $(cmd), and \w (or backslash anything) are expanded by PS1 which can cause syntax errors.

ps=${ps@Q} can't save us since that will also quote ?, ~, *, and other character that we don't want to have quoted.

To avoid this problem, I have added, after the loop, this:

ps=${ps%/} ps=${ps//\\/\\\\} ps=${ps//\`/\\\`}

We were already doing ps=${ps%/} if $PWD != /; in this branch, $PWD is always going to be != / so we don't have to check.

The other parameter expansions add a backslash before every \, and `.

You could also add one for $, but, unless I'm missing something, it should not be necessary since, even if there is a $, it can only be in the forms of $/, or $' which do not expand in PS1.


I think this is pretty much all.

Cheers.


the full ~/.bash/prompt I have been using

#!/bin/bash
#  _               _                                       _
# | |__   __ _ ___| |__    _ __  _ __ ___  _ __ ___  _ __ | |_
# | '_ \ / _` / __| '_ \  | '_ \| '__/ _ \| '_ ` _ \| '_ \| __|
# | |_) | (_| \__ \ | | | | |_) | | | (_) | | | | | | |_) | |_
# |_.__/ \__,_|___/_| |_| | .__/|_|  \___/|_| |_| |_| .__/ \__|
#                         |_|                       |_|

: "${C_DEFAULT:=$(tput sgr0)}"
: "${C_RED:=$(tput setaf 1)}"
: "${C_GREEN:=$(tput setaf 2)}"
: "${C_YELLOW:=$(tput setaf 3)}"
: "${C_BLUE:=$(tput setaf 4)}"
: "${C_MAGENTA:=$(tput setaf 5)}"
: "${C_CYAN:=$(tput setaf 6)}"

updateprompt()
{
    # ps: pathstring
    # ts: timestring
    # tc: timecolour
    local IFS d ps ts tc path_dirs

    (( ts = ($(date +%s%N) - _prompt_timer) / 1000000 ))

    case "$(( ts <= 20  ? 1 :
              ts <= 100 ? 2 :
              ts <= 250 ? 3 :
              ts <= 500 ? 4 :
              ts <= 999 ? 5 : 6 ))" in
        (1) tc=$C_GREEN                   ;;
        (2) tc=$C_YELLOW                  ;;
        (3) tc=$C_CYAN                    ;;
        (4) tc=$C_BLUE                    ;;
        (5) tc=$C_MAGENTA                 ;;
        (*) tc=$C_RED
            (( ts = (ts / 1000) % 1000 )) ;;
    esac

    printf -v ts %03d "$ts"

    case $PWD in
        "$_last_prompt_path") ps=$_last_prompt_string ;;
        "$HOME")              ps='~'                  ;;
        /)                    ps=/                    ;;
        *)
            < <(printf '%s/\0' "${PWD/#"$HOME/"/'~/'}") \
                IFS=/ read -rd '' -a path_dirs
            for d in "${path_dirs[@]}"; do
                if [[ $d =~ [[:alnum:]] || $d =~ [[:print:]] || -z $d ]]
                    then             : "${BASH_REMATCH[0]}"
                    else d=${d:0:1}; : "${d@Q}"
                fi
                ps+=$_/
            done
            ps=${ps%/} ps=${ps//\\/\\\\} ps=${ps//\`/\\\`}
            ;;
    esac

    PS1="\[${tc}\]$ts\[${C_DEFAULT}\] $ps \[${C_RED}\]>\[${C_DEFAULT}\] " \
    _last_prompt_path=$PWD \
    _last_prompt_string=$ps

    unset _prompt_timer
}

trap ': "${_prompt_timer:=$(date +%s%N)}" "$_"' DEBUG

PROMPT_COMMAND=updateprompt

PS: I only put quotes around the $(()) in the first case because vim can't highlight the code properly without them. They are not necessary.

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions