如何扩展PS1?

27

我有一个Shell脚本,在几个目录中运行相同的命令(fgit)。对于每个目录,我想要显示当前提示符和将在该目录中运行的命令的字符串。如何获取与解码(扩展)PS1相对应的字符串?例如,我的默认PS1是:

${debian_chroot:+($debian_chroot)}\[\e[1;32m\]\u\[\e[0m\]@\[\e[1;32m\]\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]$(__git_ps1 ' (%s)')$

我想要回显结果的提示符username@hostname:/path$,最好(但不一定)带有漂亮的颜色。浏览Bash手册并没有找到明确的答案,echo -e $PS1只会评估颜色。


我的头痛。我知道你可能想使用“eval echo”习语,但我不知道如何安全地传递颜色。(我的提示甚至更糟糕 - 我将其基于退出状态设置为红/绿色,因此必须在扩展之后处理颜色的转义字符。) - Cascabel
我目前的猜测是,如果我们能找到将\ u扩展为username的内容,那就很容易了。但它没有记录(我不知道足够的C语言来挖掘Bash)。 - l0b0
看起来要运行的命令是Bash源代码树中的subst.c文件中的expand_prompt_string。现在要弄清楚如何从脚本内部调用它... - l0b0
@I0b0:哦,对了,我忘记了像\u这样的额外指令。而且...我不确定expand_prompt_string是否可以从shell中访问,除非你打补丁。(也许你可以单独编译它...) - Cascabel
7个回答

23
自从Bash 4.4版本起,您可以使用@P扩展:
首先,我将您的提示字符串放在一个变量myprompt中,使用read -r和带引号的here-doc。
read -r myprompt <<'EOF'
${debian_chroot:+($debian_chroot)}\[\e[1;32m\]\u\[\e[0m\]@\[\e[1;32m\]\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]$(__git_ps1 ' (%s)')$ 
EOF

如果您想打印提示符(就像它被解释为PS1一样),请使用扩展表达式${myprompt@P}

$ printf '%s\n' "${myprompt@P}"
gniourf@rainbow:~$
$

(实际上,这里有一些来自\[\]\001\002字符,在这里你看不到它们,但如果尝试编辑此帖子,就可以看到它们;如果输入命令,你也可以在终端上看到它们)。


为了摆脱这些字符,Dennis Williamson 在 bash 邮件列表中发送的技巧是使用 read -e -p,使得 readline 库解释这些字符:

read -e -p "${myprompt@P}"

这会提示用户,使用正确的方式解释myprompt

对于这篇帖子,Greg Wooledge回答说,您可以将字符串中的\001\002删除。可以通过以下方法实现:

myprompt=${myprompt@P}
printf '%s\n' "${myprompt//[$'\001'$'\002']}"

对于此帖,Chet Ramey回答说,您也可以使用set +o emacs +o vi来完全关闭行编辑。因此,这也可以:

( set +o emacs +o vi; printf '%s\n' "${myprompt@P}" )

已在Bash 4.4 RC1上进行了测试,它有点奏效。 printf'%s\n' "${PS1@P}" 将我的提示符打印出我所看到的完全相同,但当我尝试使用 printf'%q\n' "${PS1@P}" 时,我会看到用户名和主机名两次,以及路径一次,其中一个是带 ~ 而另一个带 /home/username: $'username\001\E[1m\002\001\E[33m\002^2\001\E(B\E[m\002@hostname:\001\E[1m\002\001\E[34m\002~/download/bash-4.4-rc1\001\E(B\E[m\002\001\E]0;username@hostname:/home/username/download/bash-4.4-rc1\a\002\n$ '。很奇怪。 - l0b0
1
@lobo 如果你运行 echo "$PS1",你会看到类似 \[\e]0;\u@\h:$PWD\a\] 的东西。这会为 xterm 兼容终端设置标题。 - wjandrea
1
通常使用PROMPTCOMMAND将“提示时间要做的事情”与“在提示中显示的内容”分开是一个好主意。 - chepner

12

开源软件的一个伟大优势是其源代码是公开的 :-)

Bash本身不提供此功能,但您可以使用各种技巧来提供子集(例如将\u替换为$USER等)。但是,这需要大量的功能复制,并确保代码与未来的bash同步。

如果您想获得提示变量的全部功能(并且您不介意通过一些编码来实现(如果您介意,那你为什么会在这里呢?)),那么将其添加到shell本身就很容易。

如果您下载bash的代码(我正在查看版本4.2),则有一个y.tab.c文件,其中包含decode_prompt_string()函数:

char *decode_prompt_string (string) char *string; { ... }

这是用于评估提示的PSx变量的函数。为了让此功能可以提供给shell本身的用户(而不仅仅是被shell使用),您可以按照以下步骤添加内部命令evalps1
首先,更改support/mkversion.sh,以便不会将其与“真正的”bash混淆,并且FSF可以否认所有保证方面的知识 :-) 。只需更改一行即可(我添加了-pax):
echo "#define DISTVERSION \"${float_dist}-pax\""

其次,修改builtins/Makefile.in以添加一个新的源文件。这需要执行多个步骤。

(a) 将$(srcdir)/evalps1.def添加到DEFSRC的末尾。

(b) 将evalps1.o添加到OFILES的末尾。

(c) 添加所需的依赖项:

evalps1.o: evalps1.def $(topdir)/bashtypes.h $(topdir)/config.h \
           $(topdir)/bashintl.h $(topdir)/shell.h common.h

第三步,添加 builtins / evalps1.def 文件本身,这是运行 evalps1 命令时执行的代码:

This file is evalps1.def, from which is created evalps1.c.
It implements the builtin "evalps1" in Bash.

Copyright (C) 1987-2009 Free Software Foundation, Inc.

This file is part of GNU Bash, the Bourne Again SHell.

Bash is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

Bash is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with Bash.  If not, see <http://www.gnu.org/licenses/>.

$PRODUCES evalps1.c

$BUILTIN evalps1
$FUNCTION evalps1_builtin
$SHORT_DOC evalps1
Outputs the fully interpreted PS1 prompt.

Outputs the PS1 prompt, fully evaluated, for whatever nefarious purposes
you require.
$END

#include <config.h>
#include "../bashtypes.h"
#include <stdio.h>
#include "../bashintl.h"
#include "../shell.h"
#include "common.h"

int
evalps1_builtin (list)
     WORD_LIST *list;
{
  char *ps1 = get_string_value ("PS1");
  if (ps1 != 0)
  {
    ps1 = decode_prompt_string (ps1);
    if (ps1 != 0)
    {
      printf ("%s", ps1);
    }
  }
  return 0;
}

其中大部分是GPL许可证(因为我从exit.def中进行了修改),在末尾有一个非常简单的函数来获取和解码PS1

最后,在顶层目录中构建这个东西即可:

./configure
make

bash可执行文件可以重命名为paxsh,但我怀疑它永远不会像其祖先一样普及 :-)

运行它,你就可以看到它的效果:

pax> mv bash paxsh

pax> ./paxsh --version
GNU bash, version 4.2-pax.0(1)-release (i686-pc-linux-gnu)
Copyright (C) 2011 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

pax> ./paxsh

pax> echo $BASH_VERSION
4.2-pax.0(1)-release

pax> echo "[$PS1]"
[pax> ]

pax> echo "[$(evalps1)]"
[pax> ]

pax> PS1="\h: "

paxbox01: echo "[$PS1]"
[\h: ]

paxbox01: echo "[$(evalps1)]"
[paxbox01: ]

当您将PSx变量放入提示符中时,回显$PS1只会给出变量,而evalps1命令将评估它并输出结果。

诚然,对bash进行代码更改以添加内部命令可能被一些人认为是过度的,但如果您想要完美评估PS1,这肯定是一个选项。


太酷了!使用动态加载内置库作为更容易的选择,这样任何bash进程都可以使用吗? - andrewdotn
@Andrew,我不确定DLB是否可以访问已经在shell中的代码,是吗?“命令”必须能够调用decode_prompt_string()。我可能错了(这种情况比我愿意承认的要多),但我认为DLB必须相当自包含。 - paxdiablo
是的,你说得对;我的错误。如果你有一个未剥离的bash二进制文件,你可以使用一些技巧从符号表中读取内部bash函数和变量的地址,但系统bash几乎肯定已经被剥离了 :/ - andrewdotn
我能看到使用这种方法的主要问题是:你必须运行/使用自己修改过的bash版本。由于bash源代码的高频率需要打补丁,未来维护你的版本将会有很多工作!(直到你的补丁可能被整合到官方bash源代码中)... - F. Hauri - Give Up GitHub
@F.Hauri,考虑到所需更改的本地化,编写一个脚本来拉取更新的源代码、进行修改,然后编译修改后的代码将是一件简单的事情。这正是我们在Yocto发行版构建中所做的(在do_fetchdo_compile之间有一个do_patch步骤)。另一方面,我倾向于仅在需要时更新软件,而不仅仅是因为开发人员认为我应该更新 :-) - paxdiablo
显示剩余2条评论

9

为什么不自己处理$PS1转义替换呢?像这样一系列的替换:

p="${PS1//\\u/$USER}"; p="${p//\\h/$HOSTNAME}"

顺便说一下,zsh有解释提示符转义序列的能力。
print -P '%n@%m %d'

或者

p=${(%%)PS1}

这在有限的情况下可以工作,但完整的解决方案需要许多替换,其中一些涉及更多基于现有变量的简单替换(请参见man bashPROMPTING部分); 此外,如果未来的bash版本引入新的PS1功能,则必须保持当前解决方案。 - mklement0

5
我喜欢改进Bash的想法,并且我很欣赏paxdiablo在如何修补Bash上的详细回答。我会尝试一下。
然而,不需要对Bash源代码进行修补,我有一个单行代码的技巧,它既可移植又不会重复功能,因为这个解决方法仅使用了Bash和它的内置函数。
x="$(PS1=\"$PS1\" echo -n | bash --norc -i 2>&1)"; echo "'${x%exit}'"

请注意,与tty和stdio有关的某些奇怪问题也会发生,如下所示:
x="$(PS1=\"$PS1\" echo -n | bash --norc -i 2>&1 > /dev/null)"; echo "'${x%exit}'"

所以,虽然我不理解这里的stdio在发生什么,但是我的hack在Bash 4.2,NixOS GNU/Linux上成功了。修补Bash源代码绝对是更优雅的解决方案,现在我使用Nix应该很容易和安全地完成。

1
以下变量解决了上述问题,并且也适用于bash 3.x,需要解决虚假的“bash:no job control in this shell”错误消息。请注意,在命令替换内部使用sed命令删除尾随的“exit”,以便仅使用一个命令:x=$(PS1="$PS1" "$BASH" --norc -i </dev/null 2>&1 | sed -n '${s/^\(.*\)exit$/\1/p;}') - mklement0
@mklement0 对我来说这基本可行,但是存在一个长度限制为82(?)字符的事实,超过此限制后会覆盖该行开头,使得如 FIRST_PART_LAST_PART$ 的提示变成了 ART$T_PART_LAST_P。有什么办法可以规避这个问题吗?谢谢! - David Nemeskey
@DavidNemeskey:根据我所了解,没有明确的长度限制;你描述的情况更像是在某个地方有一个\r(回车)字符。你可以使用以下命令模拟你的症状:echo $'FIRST_PART_LAST_P\rART$'。我建议使用... | od -c来检查你的输出。不过,我注意到Bash 3.x中有一件奇怪的事情:输出前面会添加一个看不见的终端转义序列,可以通过临时重置TERM变量来抑制,这样我们就可以得到TERM= PS1="$PS1" "$BASH" --norc -i </dev/null 2>&1 | sed -n '$ s/^\(.*\)exit$/\1/p' - mklement0
@mklement0 这里有一个\r,但只有当提示的长度达到81个字符时才会出现。然后,该字符被复制,并在它们之间添加了一个\r(所以我的最后一个示例是错误的,应该是PART$T_PART_LAST_P)。我尝试重置TERM变量,但输出变成了< 001 ... 001 \r \n(一个<后面跟着大约80个001)。 - David Nemeskey
@DavidNemeskey 这很奇怪 - 我无法解释。我建议您创建一个新问题并提供更多细节。 - mklement0
显示剩余3条评论

3

两种答案:"纯bash"和"bash + sed"

介绍

当然,从的4.4版本开始,正如gniourf_gniourf正确回答的那样,您需要使用参数转换

ExpPS1=${PS1@P}
echo ${ExpPS1@Q}
$'\001\E]0;user@host: ~\a\002user@host:~$ '

请查看man -Pless\ +/parameter\\\ transformation bash

但对于旧版bash,或仅用于玩弄字符串变量...

提示扩展,使用bash+sed

这是我的hack:

ExpPS1="$(bash --rcfile <(echo "PS1='$PS1'") -i <<<'' 2>&1 |
              sed ':a;$!{N;ba};s/^\(.*\n\)*\(.*\)\n\2exit$/\2/p;d')"

解释:

运行 bash --rcfile <(echo "PS1='$PS1'") -i <<<'' 2>&1

可能会返回类似以下内容:

user@host:~$ 
user@host:~$ exit

sed命令将执行以下操作:

  • 将所有行放入一个缓冲区中 (:a;$!{N;ba};),然后
  • 通过 s/^\(.*\n\)*\(.*\)\n\2exit$/\2/ 替换 <everything, terminated by end-of-line><prompt>end-of-line<prompt>exit<prompt>
    • 其中 <everything, terminated by end-of-line> 变成了 \1
    • <prompt> 变成了 \2

测试用例:

while ExpPS1="$(bash --rcfile <(echo "PS1='$PS1'") -i <<<'' 2>&1 |
          sed ':a;$!{N;ba};s/^\(.*\n\)*\(.*\)\n\2exit$/\2/p;d')"
    read -rp "$ExpPS1" && [ "$REPLY" != exit ] ;do
    eval "$REPLY"
  done

从那里开始,您就处于一种伪交互式 shell(没有 readline 设施,但这并不重要)...

ubuntu@ubuntu:~$ cd /tmp
ubuntu@ubuntu:/tmp$ PS1="${debian_chroot:+($debian_chroot)}\[\e[1;32m\]\u\[\e[0m\]@\[\e[1;32m\]\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]$ "
ubuntu@ubuntu:/tmp$ 

(最后一行打印出绿色的ubuntu@:和黑色的$ 以及路径(/tmp)以蓝色显示)
ubuntu@ubuntu:/tmp$ exit
ubuntu@ubuntu:/tmp$ od -A n -t c <<< $ExpPS1 
 033   [   1   ;   3   2   m   u   b   u   n   t   u 033   [   0
   m   @ 033   [   1   ;   3   2   m   u   b   u   n   t   u 033
   [   0   m   : 033   [   1   ;   3   4   m   ~ 033   [   0   m
   $  \n

纯净的

简单快速:

ExpPS1="$(bash --rcfile <(echo "PS1='$PS1'") -i <<<'' 2>&1)"
mapfile ExpPS1 <<<"${ExpPS1%exit}"
ExpPS1=( "${ExpPS1[*]::${#ExpPS1[@]}/2}" )

然后现在

declare -p ExpPS1
declare -a ExpPS1=([0]=$'\E]0;ubuntu@ubuntu: ~\aubuntu@ubuntu:~$ \n')

或者

echo ${ExpPS1@Q}
$'\E]0;ubuntu@ubuntu: ~\aubuntu@ubuntu:~$ \n'

多行提示快速测试:

ExpPS1="$(bash --rcfile <(echo "PS1='Test string\n$(date)\n$PS1'"
    ) -i <<<'' 2>&1)";
mapfile ExpPS1 <<<"${ExpPS1%exit}"
ExpPS1=( "${ExpPS1[*]::${#ExpPS1[@]}/2}" )    

echo ${ExpPS1@Q}
$'Test string\r\n Sat Jan 9 19:23:47 CET 2021\r\n \E]0;ubuntu@ubuntu: ~\aubuntu@ubuntu:~$ \n'

或者

od -A n -t c  <<<${ExpPS1}
   T   e   s   t       s   t   r   i   n   g  \r  \n       S   a
   t       J   a   n           9       1   9   :   2   6   :   3
   9       C   E   T       2   0   2   1  \r  \n     033   ]   0
   ;   u   b   u   n   t   u   @   u   b   u   n   t   u   :    
   ~  \a   u   b   u   n   t   u   @   u   b   u   n   t   u   :
   ~   $      \n  \n

请注意,您可以添加一些测试来确保字符串正确:
ExpPS1="$(bash --rcfile <(echo "PS1='$PS1'") -i <<<'' 2>&1)"
mapfile ExpPS1 <<<"${ExpPS1%exit}"
[ "${ExpPS1[*]::${#ExpPS1[@]}/2}" = "${ExpPS1[*]: -${#ExpPS1[@]}/2}" ] ||
    echo WARNING: First half seem not match last half string.
ExpPS1=( "${ExpPS1[*]::${#ExpPS1[@]}/2}" )

两种方法都使用PS1='Test string\n\D{%a %d %b %Y, %Hh, %Mm, %Ss.}\n\[\e]0;\u@\h: \w\a\]${debian_chroot:+($debian_chroot)}\u@\h:\w\$ '进行了测试。 - F. Hauri - Give Up GitHub

1
还有一种可能性:不需要编辑bash源代码,可以使用script实用程序(ubuntu上的bsdutils软件包的一部分):
$ TEST_PS1="\e[31;1m\u@\h:\n\e[0;1m\$ \e[0m"
$ RANDOM_STRING=some_random_string_here_that_is_not_part_of_PS1
$ script /dev/null <<-EOF | awk 'NR==2' RS=$RANDOM_STRING
PS1="$TEST_PS1"; HISTFILE=/dev/null
echo -n $RANDOM_STRING
echo -n $RANDOM_STRING
exit
EOF
<prints the prompt properly here>

script 命令生成指定的文件,并将输出显示在标准输出上。如果省略文件名,则会生成一个名为 typescript 的文件。

由于我们在这种情况下不需要日志文件,因此将文件名指定为 /dev/null。而是将 script 命令的标准输出传递给 awk 进行进一步处理。

  1. 整个代码也可以封装成一个函数。
  2. 此外,输出提示也可以分配给一个变量。
  3. 这种方法还支持解析 PROMPT_COMMAND ...

有趣的方法,但正如你提到Ubuntu一样,它仅限于运行_GNU_实用程序的平台(其他平台,如OSX,具有相同名称的实用程序,但它们的行为不同),而bash在更多平台上运行。还要注意,script将提示字符串中的\n转换为\r\n - mklement0

0
  1. 使用 ps=${ps@P} 进行扩展(bash 4.4)
  2. 删除 \x01\x02 之间的内容(由 bash 替换 \[\] 占位符而创建)。
  3. 检查所有剩余字符
ps1_size(){
  # Ref1: https://dev59.com/hXA75IYBdhLWcg3wJFcL
  >&2 echo -e "\nP0: Raw"
  local ps=$PS1
  echo -n "$ps" | xxd >&2 

  >&2 echo -e "\nP1: Expanding (require bash 4.4)"
  ps=${ps@P}
  echo -n "$ps" | xxd >&2 

  >&2 echo -e "\nP2: Removing everything 01 and 02"
  shopt -s extglob
  ps=${ps//$'\x01'*([^$'\x02'])$'\x02'}
  echo -n "$ps" | xxd >&2 

  >&2 echo -e "\nP3: Checking"
  if [[ "$ps" =~ [\x07\x1b\x9c] ]]; then
    # Check if escape inside
    # 07 => BEL
    # 1b => ESC
    # 9C => ST
    >&2 echo 'Warning: There is an escape code in your PS1 which is not betwwen \[ \]'
    >&2 echo "Tip: put \[ \] around your escape codes (ctlseqs + associated parameters)"
    echo -n "$ps" | xxd >&2
  # Check printable characters <= 20 .. 7e, and newline
  # -- Remove the trailing 0x0a (BEL)
  elif [[ "$ps" =~ [^[:graph:][:space:]] ]]; then
    >&2 echo 'Warning: There is a non printable character in PS1 which is not between \[ \]'
    >&2 echo "Tip: put \[ \] around your escape codes (ctlseqs + associated parameters)"
    echo "$ps"
    echo -n "$ps" | xxd >&2 
  fi

  # Echo result
  echo -n "${#ps}"
}

ps1_size


应该输出类似于这样的内容:
~/Software/Bash/Mouse (master)$ source ../ps1_size.sh

P0: Raw
00000000: 5c5b 5c65 5d30 3b60 7061 7273 655f 7469  \[\e]0;`parse_ti
00000010: 746c 6560 5c30 3037 5c5d 5c5b 5c65 5b33  tle`\007\]\[\e[3
00000020: 326d 5c5d 5c77 205c 5b5c 655b 3333 6d5c  2m\]\w \[\e[33m\
00000030: 5d60 7061 7273 655f 6769 745f 6272 616e  ]`parse_git_bran
00000040: 6368 605c 5b5c 655b 306d 5c5d 2420       ch`\[\e[0m\]$

P1: Expanding (require bash 4.4)
00000000: 011b 5d30 3b7e 2f53 6f66 7477 6172 652f  ..]0;~/Software/
00000010: 4261 7368 2f4d 6f75 7365 0702 011b 5b33  Bash/Mouse....[3
00000020: 326d 027e 2f53 6f66 7477 6172 652f 4261  2m.~/Software/Ba
00000030: 7368 2f4d 6f75 7365 2001 1b5b 3333 6d02  sh/Mouse ..[33m.
00000040: 286d 6173 7465 7229 011b 5b30 6d02 2420  (master)..[0m.$

P2: Removing everything 01 and 02
00000000: 7e2f 536f 6674 7761 7265 2f42 6173 682f  ~/Software/Bash/
00000010: 4d6f 7573 6520 286d 6173 7465 7229 2420  Mouse (master)$

P3: Checking
32~/Software/Bash/Mouse (master)$

如果存在某些控制字符,您可以按照stackoverflow: Removing ANSI color codes from text stream中所述的方法将其删除。 我使用以下内容从github: mouse_xterm中删除SCI和OSC。
  # Sanitize, in case
  ps=$(LC_ALL=C sed '
    # Safety
    s/\x01\|\x02//g;
    # Safety Remove OSC https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
    # 20 .. 7e => printable characters
    # 07 => BEL
    # 9C => ST
    # 1b 5C => ESC + BS
    s/\x1b\][0-9;]*[\x20-\x7e]*\([\x07\x9C]\|\x1b\\\)//g;
    # Safety: Remove all escape sequences https://superuser.com/questions/380772/removing-ansi-color-codes-from-text-stream
    s/\x1b\[[0-9;]*[a-zA-Z]//g;
  ' <<< "$ps")

当链接到您自己的网站或内容(或与您有关联的内容)时,您必须在答案中披露您的关联,以便不被视为垃圾邮件。在用户名中具有与URL相同的文本或在个人资料中提及它不被视为足够的披露根据Stack Exchange政策。 - cigien
嗨@cigien,感谢您的评论。我延迟并解释了我的代码链接。引用的代码展示了所呈现函数的用法。 - Tinmarino

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接