add custom prompts with PS1 and PS2
PS1 and PS2 support the backslash escapes of bash (\u \h \w \W \$ \t \j
\e \[ \] ...) and expand $VAR, ${...}, $(...) and $((...)) before each
prompt. Values of escapes and expansions are sanitized. Lines before the
last one and non-SGR escape sequences are written before readline's
prompt. Without PS1, the default prompt is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af5281eed2
commit
82f9c90a39
6 files changed
+528
-13
No files matched your search
+125
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// unsetPS1 removes $PS1 for the test, so that the default prompt is used.
|
||||
func unsetPS1(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("PS1", "")
|
||||
os.Unsetenv("PS1")
|
||||
}
|
||||
|
||||
var testInfo = promptInfo{
|
||||
user: "flow", host: "box.example.com", wd: "/home/flow/src/gosh", home: "/home/flow",
|
||||
jobs: 2, tty: "pts/3", now: time.Date(2026, 10, 5, 14, 7, 9, 0, time.UTC),
|
||||
}
|
||||
|
||||
func TestExpandPrompt_Escapes(t *testing.T) {
|
||||
t.Setenv("GOSH_PS_VAR", "val")
|
||||
rootInfo := testInfo
|
||||
rootInfo.root, rootInfo.wd = true, "/"
|
||||
homeInfo := testInfo
|
||||
homeInfo.wd = "/home/flow"
|
||||
tests := []struct {
|
||||
ps string
|
||||
info promptInfo
|
||||
want string
|
||||
}{
|
||||
{`\u@\h:\w\$ `, testInfo, "flow@box:~/src/gosh$ "},
|
||||
{`\u@\H \W \$ `, rootInfo, "flow@box.example.com / # "},
|
||||
{`\W`, homeInfo, "~"},
|
||||
{`\W`, testInfo, "gosh"},
|
||||
{`\t \T \@ \A \d`, testInfo, "14:07:09 02:07:09 02:07 PM 14:07 Mon Oct 05"},
|
||||
{`\j \l \s`, testInfo, "2 pts/3 gosh"},
|
||||
{`a\nb\\c`, testInfo, "a\nb\\c"},
|
||||
{`\[\e[1;32m\]x\[\033[0m\]`, testInfo, "\x1b[1;32mx\x1b[0m"},
|
||||
{`\101\400`, testInfo, "A 0"},
|
||||
{`\q\`, testInfo, `\q\`},
|
||||
{`$GOSH_PS_VAR ${GOSH_PS_VAR}-$((1+2)) $(echo sub) $?`, testInfo, "val val-3 sub 0"},
|
||||
{`'$GOSH_PS_VAR' "x"`, testInfo, `'val' "x"`},
|
||||
{`cost: 5$ \$`, testInfo, "cost: 5$ $"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := expandPrompt(tt.ps, tt.info)
|
||||
if err != nil {
|
||||
t.Errorf("expandPrompt(%q): %v", tt.ps, err)
|
||||
} else if got != tt.want {
|
||||
t.Errorf("expandPrompt(%q) = %q, want %q", tt.ps, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPrompt_Errors(t *testing.T) {
|
||||
os.Unsetenv("GOSH_PS_UNSET")
|
||||
if _, err := expandPrompt(`$GOSH_PS_UNSET`, testInfo); err == nil {
|
||||
t.Error("expected an error for an unset variable")
|
||||
}
|
||||
if got, err := expandPrompt(`${GOSH_PS_UNSET:-none}`, testInfo); err != nil || got != "none" {
|
||||
t.Errorf("got %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPrompt_KeepsStatus(t *testing.T) {
|
||||
lastStatus, substStatus = 3, 0
|
||||
defer func() { lastStatus = 0 }()
|
||||
got, err := expandPrompt(`$(exit 7)$?`, testInfo)
|
||||
if err != nil || got != "3" {
|
||||
t.Errorf("got %q, %v", got, err)
|
||||
}
|
||||
if substStatus != 0 {
|
||||
t.Errorf("substStatus = %d, want 0", substStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPrompt_Sanitizes(t *testing.T) {
|
||||
t.Setenv("GOSH_PS_EVIL", "a\x1b[2Jb")
|
||||
info := testInfo
|
||||
info.wd = "/tmp/evil\x1b]0;x\x07"
|
||||
got, err := expandPrompt(`\w $GOSH_PS_EVIL $(printf 'c\033[2Jd')`, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.ContainsAny(got, "\x1b\x07") {
|
||||
t.Errorf("prompt contains control characters: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPrompt(t *testing.T) {
|
||||
tests := []struct{ prompt, prefix, line string }{
|
||||
{"a $ ", "", "a $ "},
|
||||
{"top\n\x1b[1mbold\x1b[0m $ ", "top\n", "\x1b[1mbold\x1b[0m $ "},
|
||||
{"l1\nl2\n$ ", "l1\nl2\n", "$ "},
|
||||
{"\x1b]0;title\x07$ ", "\x1b]0;title\x07", "$ "},
|
||||
{"\x1b]0;t\x1b\\\x1b[2K$ \a", "\x1b]0;t\x1b\\\x1b[2K\a", "$ "},
|
||||
{"x\x1b", "\x1b", "x"},
|
||||
{"x\x1b[", "\x1b[", "x"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
prefix, line := splitPrompt(tt.prompt)
|
||||
if prefix != tt.prefix || line != tt.line {
|
||||
t.Errorf("splitPrompt(%q) = %q, %q, want %q, %q", tt.prompt, prefix, line, tt.prefix, tt.line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPrompt_PS1(t *testing.T) {
|
||||
t.Setenv("PS1", `[\s] \$ `)
|
||||
want := "[gosh] $ "
|
||||
if os.Geteuid() == 0 {
|
||||
want = "[gosh] # "
|
||||
}
|
||||
if got := getPrompt(); got != want {
|
||||
t.Errorf("getPrompt() = %q, want %q", got, want)
|
||||
}
|
||||
// An invalid PS1 falls back to the default prompt
|
||||
t.Setenv("PS1", `$GOSH_PS_UNSET`)
|
||||
os.Unsetenv("GOSH_PS_UNSET")
|
||||
if got := getPrompt(); !strings.Contains(got, "@") {
|
||||
t.Errorf("expected default prompt, got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user