youtube icon indicating copy to clipboard operation
youtube copied to clipboard

Sharing some prompt patches

Open emanuele6 opened this issue 3 years ago • 10 comments

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.

emanuele6 avatar Aug 14 '21 08:08 emanuele6

NICE! This looks so much cleaner then the original. I have been thinking that i should revisit the prompt code, but you know ;) One thing i haven't researched or tested at all is the "new" built in bash variable $EPOCHREALTIME , which i think might make the timer even more efficient if we can use that instead of date.

from man bash

EPOCHREALTIME
              Each  time  this  parameter  is referenced, it expands to the number of seconds since the Unix
              Epoch (see time(3)) as a floating point value with micro-second granularity.   Assignments  to
              EPOCHREALTIME  are  ignored.  If EPOCHREALTIME is unset, it loses its special properties, even
              if it is subsequently reset.

Also, mind blown by that if ... then : ... else : format! I wan't to rewrite all my ifs now.


Regarding the comment on syntax highlighting, this is one of the two reasons i avoid syntax highlighting, even in the generated markdown codeblock in your comment the highlighting is broken.

Thanks for this issue, I have been lowkey trying to get the "community" thing on i3 ass going, but this reminded me of the existance of this repository and that it is better suited for, well, this stuff.

budRich avatar Aug 14 '21 09:08 budRich

i don't know if this is a better way, but it is cool that it can be done without printf or date:

#!/bin/bash

declare -i seconds_start seconds_end micro_end micro_start milliseconds micro_diff seconds_diff
# 1628937701.872723

stt=$EPOCHREALTIME
sleep .05
end=$EPOCHREALTIME

re='([0-9]+)[.]0*([0-9]*)[0-9]{3}:([0-9]+)[.]0*([0-9]*)[0-9]{3}'

[[ ${stt}:${end} =~ $re ]] && {
	seconds_start=${BASH_REMATCH[1]} micro_start=${BASH_REMATCH[2]}
	seconds_end=${BASH_REMATCH[3]}   micro_end=${BASH_REMATCH[4]}

	micro_diff="micro_end - micro_start"
	seconds_diff="(seconds_end - seconds_start) * 1000"

	milliseconds="micro_diff + seconds_diff"

	echo "$milliseconds"
}

budRich avatar Aug 14 '21 11:08 budRich

Wow! That is great!

I changed:

# (( ts = ($(date +%s%N) - _prompt_timer) / 1000000 ))
(( ts = (10#${EPOCHREALTIME//[!0-9]} - _prompt_timer) / 1000 ))

and:

# trap ': "${_prompt_timer:=$(date +%s%N)}" "$_"' DEBUG
trap ': "${_prompt_timer:=$(( 10#${EPOCHREALTIME//[!0-9]} ))}" "$_"' DEBUG

I checked the format for EPOCHREALTIME in bash's source code; it is:

snprintf (buf, sizeof (buf), "%u%c%06u", (unsigned)tv.tv_sec,
                     locale_decpoint (),
                     (unsigned)tv.tv_usec);

Since the format for the microseconds is %06u it is always safe to remove the . and treat the resulting string as an integer number because the fractional part of the number will always have six digits.

I used ${EPOCHREALTIME//[!0-9]} instead of just ${EPOCHREALTIME//.} because the decimal separator can change based on locale: it could be , in some locales for example.

Then I used 10# to avoid treating the number as octal (base-8) if tv_sec is 0 (which should never happen obviously, but just for correctness's sake):

printf '%s\n' "$(( 010 ))" "$(( 10#010 ))"
# 8
# 10

Now my prompt always says 000 instead of 003 or 004 when I hold down enter. And, except the initial tput that only runs once when the file is sourced and is optional anyway, It is now also all pure bash! This is so flippin' cool! :D

EDIT: Also, no subshells!


full ~/.bash/prompt

#!/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 = (10#${EPOCHREALTIME//[!0-9]} - _prompt_timer) / 1000 ))

    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:=$(( 10#${EPOCHREALTIME//[!0-9]} ))}" "$_"' DEBUG

PROMPT_COMMAND=updateprompt

emanuele6 avatar Aug 14 '21 12:08 emanuele6

Also, mind blown by that if ... then : ... else : format! I wan't to rewrite all my ifs now.

I love it too! It also works wonderfully with case $var in pat1) : ... ;; pat2) : ... ;; *) : ...;; esac.

emanuele6 avatar Aug 14 '21 13:08 emanuele6

Thanks for this issue, I have been lowkey trying to get the "community" thing on i3 ass going, but this reminded me of the existance of this repository and that it is better suited for, well, this stuff.

Having such a thing would be great! Youtube has started shadowing all my comments since the beginning of July and my comments never go through. I don't even know why. :/

Maybe because I don't watch enough ads or because I stopped using the official android app (since the end of last october) in favour of just using my android web browser (fennec f-droid; i.e. firefox) that let's me use ublock origin and watch/listen to videos in the background and in picture-in-picture even on youtube.com, idk.

The 29th of october, I got an official samsung ad that was advertising a contest called "Galaxy S Unpacked per ogni Fan" which I believe was a contest to potentially win their new phone. While it was playing the ad's video, it also opened a form at the bottom that had all my personal information automatically filled in (phone number, first name, last name, email, postal code) and just a checkbox and a button to submit the form at the bottom of the screen where I almost clicked it by accident. I had never gotten that kind of ad and it got me extremely mad so I immediately decided to remove that garbage app from my phone... and then I also discovered that watching videos from the browser, at least fennec was much better anyway.


Also, don't forget about the budlabs gitter.im chat. ;) I don't use gitter at all, but I still get mail notifications when someone sends a message.

emanuele6 avatar Aug 14 '21 13:08 emanuele6

Yeah i actually got a notification on youtube that you had made a comment a couple of days ago (about xdo pid stuff). But it is not visible on the video or in my channels control panel, i could only see it in the notification bell thing. Which doesn't show the whole comment. This seems to be the same as if someone makes a comment but later regret it and delete it, but i have seen it more frequently lately for more users and the comments hasn't been the kind one would regret.

And i think my whole channel is in some shadow mode, the statistics have felt very off this year. I will try to figure out something, maybe just move to odyssee, i think its possible to "reverse" synk, so i upload to odyssee and have it synked to youtube, if not, maybe just upload there. I know other linux tubers do that (odyssee only).

budRich avatar Aug 14 '21 17:08 budRich

@budRich

I am writing a comment here, because, earlier this morning, something (that I eventually figured out was being caused by this prompt) drove me insane for a good half hour, lmao.

version of the prompt that I was using (I refactored it a little bit since the last time I shared it)

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

prompt_color_red=$(    tput setaf 1) \
prompt_color_green=$(  tput setaf 2) \
prompt_color_yellow=$( tput setaf 3) \
prompt_color_blue=$(   tput setaf 4) \
prompt_color_magenta=$(tput setaf 5) \
prompt_color_cyan=$(   tput setaf 6) \
prompt_color_reset=$(  tput sgr0   ) \
prompt_time_color= prompt_time_text= \
prompt_path=

updateprompt () {
    local IFS d path_dirs

    prompt_time_text=$((
        (10#${EPOCHREALTIME//[!0123456789]} - prompt_timer) / 1000
    ))

    case $(( prompt_time_text <= 20  ? 1 :
             prompt_time_text <= 100 ? 2 :
             prompt_time_text <= 250 ? 3 :
             prompt_time_text <= 500 ? 4 :
             prompt_time_text <= 999 ? 5 : 6 )) in
        1) : "$prompt_color_green"   ;;
        2) : "$prompt_color_yellow"  ;;
        3) : "$prompt_color_cyan"    ;;
        4) : "$prompt_color_blue"    ;;
        5) : "$prompt_color_magenta" ;;
        *) prompt_time_text=$(( (prompt_time_text / 1000) % 1000 ))
           : "$prompt_color_red"
    esac
    prompt_time_color=$_

    printf -v prompt_time_text %03d "$prompt_time_text"

    case $PWD in
        "$HOME") prompt_path='~' ;;
        /)       prompt_path=/   ;;
        *)
            prompt_path= \
            path_dirs=${PWD/#"$HOME/"/'~/'}
            mapfile -t path_dirs <<< "${path_dirs//\//$'\n'}"
            for d in "${path_dirs[@]}"; do
                # subshell to prevent overwriting global BASH_REMATCH
                prompt_path+=$(
                    if [[
                        $d =~ [[:alnum:]] ||
                        $d =~ [[:print:]] ||
                        -z $d
                    ]]; then
                        : "${BASH_REMATCH[0]}"
                    else
                        d=${d:0:1}; : "${d@Q}"
                    fi
                    printf %s/ "$_"
                )
            done
            prompt_path=${prompt_path%/}
    esac

    printf -v PS1 %s \
        '\[$prompt_time_color\]$prompt_time_text' \
        '\[$prompt_color_reset\] $prompt_path \[$prompt_color_red\]>' \
        '\[$prompt_color_reset\] '

    unset -v prompt_timer
}

trap '
    : "${prompt_timer:=$(( 10#${EPOCHREALTIME//[!0123456789]} ))}" "$_"
' DEBUG

PROMPT_COMMAND=( updateprompt )

I was comparing these two loops because i was wondering if writing to a pipe that writes to /dev/null indirectly would be much slower than writing directly to /dev/null:

000 ~ > time for (( i = 0; i < 10000; ++i )); do printf 'xxxxxx'; done > /dev/null

real    0m0.552s
user    0m0.490s
sys     0m0.060s
551 ~ > time for (( i = 0; i < 10000; ++i )); do printf 'xxxxxx'; done | cat - > /dev/null

real    0m0.106s
user    0m0.085s
sys     0m0.064s

104 ~ >

Huh? It seems piping to cat - > /dev/null actually makes the loop 5.5 times faster. Wow!

I tried writing the outputs to two files instead of /dev/null and diffing the files to make sure they actually had the same output, and again, I got roughly the same times! Wow, what in the world? How is that possible? Piping to cat that writes to a file instead of directly writing to a file makes the script more than 5 times faster? How is that even possible?

I tried investigating this with strace, but I couldn't see anything interesting, except that, when using time strace bash -c '...', both scripts where taking roughly the same time to run instead of one being 5x faster than the other.

I eventually tried to run the for loop in a subshell (since | cat - would also run it in a subshell) and got this result:

000 ~ > time (for (( i = 0; i < 10000; ++i )); do printf 'xxxxxx'; done > /dev/null)

real    0m0.057s
user    0m0.057s
sys     0m0.000s

Huh? Simply running the code in a subshell made it more than 10 times faster?? Is bash able to do some crazy optimisation because it knows it will exit after the for loop terminates? That is cool! Let me share a paste of this!

So I ran the commands in a bash --norc --noprofile shell (I usually always use --norc --noprofile when sharing pastes so that my prompt doesn't distract too much from the content of the paste):

bash-5.1$ time (for (( i = 0; i < 10000; ++i )); do printf 'xxxxxx'; done > /dev/null)

real    0m0.062s
user    0m0.062s
sys     0m0.000s

bash-5.1$ time for (( i = 0; i < 10000; ++i )); do printf 'xxxxxx'; done > /dev/null

real    0m0.064s
user    0m0.064s
sys     0m0.000s

Huh? This time both loops finished in basically the same time? Ohhhhhhhhhhhhhhh, it's the DEBUG trap for my prompt that is making the code 10 TIMES SLOWER! xD

note: subshells don't inherit DEBUG traps.

Well that was confusing... And that slowdown is quite substantial, that blows!


Fortunately, the fix for this is quite simple; just remove the DEBUG trap after setting prompt_timer and only re-add it when you need it:

the current version of my prompt

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

prompt_color_red=$(    tput setaf 1) \
prompt_color_green=$(  tput setaf 2) \
prompt_color_yellow=$( tput setaf 3) \
prompt_color_blue=$(   tput setaf 4) \
prompt_color_magenta=$(tput setaf 5) \
prompt_color_cyan=$(   tput setaf 6) \
prompt_color_reset=$(  tput sgr0   ) \
prompt_time_color= prompt_time_text= \
prompt_path=

startprompttimer () {
    saved_last_arg=$1
    trap '
        prompt_timer=$(( 10#${epochrealtime//[!0123456789]} ))
        trap - DEBUG
        : "$saved_last_arg"
    ' DEBUG
}

updateprompt () {
    local IFS d path_dirs

    prompt_time_text=$((
        (10#${EPOCHREALTIME//[!0123456789]} - prompt_timer) / 1000
    ))

    case $(( prompt_time_text <= 20  ? 1 :
             prompt_time_text <= 100 ? 2 :
             prompt_time_text <= 250 ? 3 :
             prompt_time_text <= 500 ? 4 :
             prompt_time_text <= 999 ? 5 : 6 )) in
        1) : "$prompt_color_green"   ;;
        2) : "$prompt_color_yellow"  ;;
        3) : "$prompt_color_cyan"    ;;
        4) : "$prompt_color_blue"    ;;
        5) : "$prompt_color_magenta" ;;
        *) prompt_time_text=$(( (prompt_time_text / 1000) % 1000 ))
           : "$prompt_color_red"
    esac
    prompt_time_color=$_

    printf -v prompt_time_text %03d "$prompt_time_text"

    case $PWD in
        "$HOME") prompt_path='~' ;;
        /)       prompt_path=/   ;;
        *)
            prompt_path= \
            path_dirs=${PWD/#"$HOME/"/'~/'}
            mapfile -t path_dirs <<< "${path_dirs//\//$'\n'}"
            for d in "${path_dirs[@]}"; do
                # subshell to prevent overwriting global BASH_REMATCH
                prompt_path+=$(
                    if [[
                        $d =~ [[:alnum:]] ||
                        $d =~ [[:print:]] ||
                        -z $d
                    ]]; then
                        : "${BASH_REMATCH[0]}"
                    else
                        d=${d:0:1}; : "${d@Q}"
                    fi
                    printf %s/ "$_"
                )
            done
            prompt_path=${prompt_path%/}
    esac

    printf -v PS1 %s \
        '\[$prompt_time_color\]$prompt_time_text' \
        '\[$prompt_color_reset\] $prompt_path \[$prompt_color_red\]>' \
        '\[$prompt_color_reset\] '
}

startprompttimer
PROMPT_COMMAND=( updateprompt 'startprompttimer "$_"' )

Unfortunately, I had to introduce another global variable to save and restore $_ (since I can't pass $_ to trap - DEBUG and since running trap - DEBUG from a function called by the DEBUG trap instead of directly from the DEBUG trap doesn't seem to work as expected).

Another possible workaround is to inject "$1" into the trap like so:

startprompttimer () {
    trap '
        prompt_timer=$(( 10#${EPOCHREALTIME//[!0123456789]} ))
        trap - DEBUG
        : '"${1@Q}"'
    ' DEBUG
}

But, as you can imagine, that is quite slow and expensive if $_ is very long, so i decided to settle with having another global variable.

Now, when I run loops interactively, they will maybe be a smidge faster! Yay \o/


Cheers and happy Holy Week, emanuele6

emanuele6 avatar Apr 16 '22 05:04 emanuele6

The disabling/re-enabling of the DEBUG trap, is making my brain hurt a bit :) This is truly awesome, thank you so much for sharing and digging.

I wish you a happy Holy Week as well my friend!

budRich avatar Apr 17 '22 16:04 budRich

Hello, bud! Sharing some more groundbreaking discoveries!

Removing need for DEBUG traps completely

PS0 can be used to set the prompt_timer variable without printing stuff out using a ${var:prompt_timer = ...:0} expansion (${paramenter:offset:length}).

There is a minor inconvenience: PS0 is not ran for empty commands, but that can be handled by unsetting prompt_timer in a PROMPT_COMMAND and defaulting prompt_time_text to 0 if it is unset.

Benefits

Using PS0 is also better because:

  1. it will run only before the actual command is run, it will not run for commands ran by the PROMPT_COMMAND, so the measured time, will not include the time took to run updateprompt (this was not a problem for the previous version that removed and reset the DEBUG trap, but it was a problem in the previous versions)
  2. PS0 does not set $_, so you don't have to do anything to preserve it!
  3. DEBUG trap are now free, and you can play with them interactively without fearing startprompttimer removing them. :)

Other differences since the last time I shared the prompt

  1. I refactored the code a bit
  2. Fix bugs caused by me using mapfile incorrectly (I don't even remember why I used it like that to be honest):
    • I could have just used -d/ instead of replacing every / with $'\n'
    • It was producing wrong results if directories containing $'\n' were present.
  3. I removed the subshell used to preserve BASH_REMATCH, instead I restore BASH_REMATCH manually: I discovered that, apparently, that is the intended way to restore BASH_REMATCH and the reason why BASH_REMATCH was made not readonly in 5.1 (it used to be readonly in 5.0 and earlier versions).
side note for 3

I discovered some time ago this fun "memory leak" caused by having a local variable named BASH_REMATCH https://lists.gnu.org/archive/html/bug-bash/2022-05/msg00052.html.

This only affects 5.1 since it requires BASH_REMATCH to not be readonly (you cannot declare local variables with the same name as a readonly variable).

It has been fixed for 5.2 in the following way:

  • if you declare a local BASH_REMATCH, that local variable will not be affected by [[ =~ ]]
  • [[ =~ ]] will only set the global BASH_REMATCH
  • if you set a local BASH_REMATCH you have no way to access the global one

So, basically, the current intended behaviour without the "leak".

But I think that Chet intends to allow declaring a local BASH_REMATCH that will be used by [[ =~ ]] for the current function as a better fix for the issue (he uploaded the implementation but it is #if 0ed), but he has not changed the behaviour yet because bash 5.2 is in feature freeze (it is already at Release Candidate 2!), so, hopefully, we will be able to just use local BASH_REMATCH in the future. :D

That bug allows implementing a fun LIFO stack with BASH_REMATCH variables; see https://gist.github.com/emanuele6/1d41604a8c233a95227c78d7f2b0a3b8 for fun things =).


Diff between my last version of the prompt using DEBUG traps, and the new version that uses PS0

19,27d18
< startprompttimer () {
<     saved_last_arg=$1
<     trap '
<         prompt_timer=$(( 10#${EPOCHREALTIME//[!0123456789]} ))
<         trap - DEBUG
<         : "$saved_last_arg"
<     ' DEBUG
< }
< 
31,33c22,28
<     prompt_time_text=$((
<         (10#${EPOCHREALTIME//[!0123456789]} - prompt_timer) / 1000
<     ))
---
>     if [[ ${prompt_timer+is_set} ]]; then
>         prompt_time_text=$((
>             (10#0${EPOCHREALTIME//[!0123456789]} - prompt_timer) / 1000
>         ))
>     else
>         prompt_time_text=0
>     fi
79c74,76
< PROMPT_COMMAND+=( updateprompt 'startprompttimer "$_"' )
---
> PS0='${PS0:prompt_timer = 10#0${EPOCHREALTIME//[!0123456789]}:0}'
> 
> PROMPT_COMMAND+=( updateprompt 'unset -v prompt_timer' )
84,85d80
< 
< startprompttimer

Current version of the prompt using PS0

code

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

prompt_color_red=$(    tput setaf 1) \
prompt_color_green=$(  tput setaf 2) \
prompt_color_yellow=$( tput setaf 3) \
prompt_color_blue=$(   tput setaf 4) \
prompt_color_magenta=$(tput setaf 5) \
prompt_color_cyan=$(   tput setaf 6) \
prompt_color_reset=$(  tput sgr0   ) \
prompt_time_color= prompt_time_text= \
prompt_path=

updateprompt () {
    local d IFS MAPFILE old_rematch_{attributes,declaration} path_parts

    if [[ ${prompt_timer+is_set} ]]; then
        prompt_time_text=$((
            (10#0${EPOCHREALTIME//[!0123456789]} - prompt_timer) / 1000
        ))
    else
        prompt_time_text=0
    fi

    if   (( prompt_time_text <=  20 )); then
        prompt_time_color=$prompt_color_green
    elif (( prompt_time_text <= 100 )); then
        prompt_time_color=$prompt_color_yellow
    elif (( prompt_time_text <= 250 )); then
        prompt_time_color=$prompt_color_cyan
    elif (( prompt_time_text <= 500 )); then
        prompt_time_color=$prompt_color_blue
    elif (( prompt_time_text <= 999 )); then
        prompt_time_color=$prompt_color_magenta
    else
        prompt_time_color=$prompt_color_red \
        prompt_time_text=$(( (prompt_time_text / 1000) % 1000 ))
    fi

    printf -v prompt_time_text %03d "$prompt_time_text"

    case $PWD in
        ~)  prompt_path='~' ;;
        /)  prompt_path=/   ;;
        *)  mapfile -td/ <<< "${PWD/#"$HOME/"/'~/'}"
            MAPFILE[-1]=${MAPFILE[-1]%$'\n'} \
            old_rematch_declaration=${BASH_REMATCH[@]@A} \
            old_rematch_attributes=${BASH_REMATCH@a} \
            path_parts=()
            for d in "${MAPFILE[@]}"; do
                if [[
                    $d =~ [[:alnum:]] || $d =~ [[:print:]] || -z $d
                ]]; then
                    path_parts+=( "${BASH_REMATCH[0]}" )
                else
                    d=${d:0:1} prompt_path+=( "${d@Q}" )
                fi
            done
            unset -v BASH_REMATCH
            if [[ $old_rematch_attributes ]]
                then eval -- "${old_rematch_declaration/-/-g}"
            elif [[ $old_rematch_declaration ]]
                then eval declare -g -- "$old_rematch_declaration"
            fi
            IFS=/ prompt_path=${path_parts[*]}
    esac
}

PS0='${PS0:prompt_timer = 10#0${EPOCHREALTIME//[!0123456789]}:0}'

PROMPT_COMMAND+=( updateprompt 'unset -v prompt_timer' )

PS1=\
'\[$prompt_time_color\]$prompt_time_text\[$prompt_color_reset\] '\
'$prompt_path \[$prompt_color_red\]>\[$prompt_color_reset\] '

My last version of the prompt that used DEBUG traps

code

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

prompt_color_red=$(    tput setaf 1) \
prompt_color_green=$(  tput setaf 2) \
prompt_color_yellow=$( tput setaf 3) \
prompt_color_blue=$(   tput setaf 4) \
prompt_color_magenta=$(tput setaf 5) \
prompt_color_cyan=$(   tput setaf 6) \
prompt_color_reset=$(  tput sgr0   ) \
prompt_time_color= prompt_time_text= \
prompt_path=

startprompttimer () {
    saved_last_arg=$1
    trap '
        prompt_timer=$(( 10#${EPOCHREALTIME//[!0123456789]} ))
        trap - DEBUG
        : "$saved_last_arg"
    ' DEBUG
}

updateprompt () {
    local d IFS MAPFILE old_rematch_{attributes,declaration} path_parts

    prompt_time_text=$((
        (10#${EPOCHREALTIME//[!0123456789]} - prompt_timer) / 1000
    ))

    if   (( prompt_time_text <=  20 )); then
        prompt_time_color=$prompt_color_green
    elif (( prompt_time_text <= 100 )); then
        prompt_time_color=$prompt_color_yellow
    elif (( prompt_time_text <= 250 )); then
        prompt_time_color=$prompt_color_cyan
    elif (( prompt_time_text <= 500 )); then
        prompt_time_color=$prompt_color_blue
    elif (( prompt_time_text <= 999 )); then
        prompt_time_color=$prompt_color_magenta
    else
        prompt_time_color=$prompt_color_red \
        prompt_time_text=$(( (prompt_time_text / 1000) % 1000 ))
    fi

    printf -v prompt_time_text %03d "$prompt_time_text"

    case $PWD in
        ~)  prompt_path='~' ;;
        /)  prompt_path=/   ;;
        *)  mapfile -td/ <<< "${PWD/#"$HOME/"/'~/'}"
            MAPFILE[-1]=${MAPFILE[-1]%$'\n'} \
            old_rematch_declaration=${BASH_REMATCH[@]@A} \
            old_rematch_attributes=${BASH_REMATCH@a} \
            path_parts=()
            for d in "${MAPFILE[@]}"; do
                if [[
                    $d =~ [[:alnum:]] || $d =~ [[:print:]] || -z $d
                ]]; then
                    path_parts+=( "${BASH_REMATCH[0]}" )
                else
                    d=${d:0:1} prompt_path+=( "${d@Q}" )
                fi
            done
            unset -v BASH_REMATCH
            if [[ $old_rematch_attributes ]]
                then eval -- "${old_rematch_declaration/-/-g}"
            elif [[ $old_rematch_declaration ]]
                then eval declare -g -- "$old_rematch_declaration"
            fi
            IFS=/ prompt_path=${path_parts[*]}
    esac
}

PROMPT_COMMAND+=( updateprompt 'startprompttimer "$_"' )

PS1=\
'\[$prompt_time_color\]$prompt_time_text\[$prompt_color_reset\] '\
'$prompt_path \[$prompt_color_red\]>\[$prompt_color_reset\] '

startprompttimer

emanuele6 avatar Jul 19 '22 17:07 emanuele6

amazing! Not having to worry about DEBUG trap being used is great.

Interesting discoveries and development regarding local BASH_REMATCH . bash mailing list is comfy.

budRich avatar Jul 20 '22 21:07 budRich