Compare commits

..
2 Commits
Author SHA1 Message Date
Florian WaltherandClaude Opus 5.5 eb8653c9f5 escape parentheses and other special characters in tab completion
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:16:17 +02:00
Florian WaltherandClaude Opus 5.5 82f9c90a39 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>
2026-10-05 22:49:19 +02:00
6 changed files with 570 additions and 16 deletions

No files matched your search

+43 -2
View File
@@ -59,6 +59,11 @@ Features:
GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values
are ANSI SGR codes, empty = no color); disabled with $NO_COLOR,
TERM=dumb or when output is not a terminal
- Custom prompt with $PS1 (and $PS2 for continuation lines, default
"> ") using the escapes of bash: \u user, \h/\H host, \w/\W directory,
\$ (# for root), \t \T \@ \A \d time/date, \j jobs, \n newline,
\e escape, \[ \]; $VAR, $(...) and $((...)) are expanded, e.g.
PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\w\$ '
- Reports the working directory to the terminal (OSC 7) before each
prompt, so new tabs/windows open in the same directory
- Variables: name=value sets a variable (like set), name=value command
@@ -725,7 +730,7 @@ $ bg %2 # oder: angehaltenen Job im Hintergrund weiterlauf
### Prompt
Der Prompt zeigt Benutzer, Rechner und Verzeichnis. Ist der letzte Befehl fehlgeschlagen, steht sein Exit-Status in eckigen Klammern davor, bis wieder ein Befehl erfolgreich war:
Der Standard-Prompt zeigt Benutzer, Rechner und Verzeichnis. Ist der letzte Befehl fehlgeschlagen, steht sein Exit-Status in eckigen Klammern davor, bis wieder ein Befehl erfolgreich war:
```
flow@host:~/src $ false
@@ -741,7 +746,7 @@ Typische Werte: `1` allgemeiner Fehler, `2` Syntaxfehler, `126` nicht ausführba
### Farben
Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`:
Der Standard-Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt (für einen eigenen Prompt mit `PS1` gilt sie nicht, siehe unten). Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`:
| Schlüssel | Teil des Prompts | Standard |
|-----------|--------------------------------|---------------------|
@@ -796,6 +801,42 @@ GOSH_COLORS='root=1;97;41'
GOSH_COLORS='user=:path=4'
```
### Eigener Prompt: PS1 und PS2
Wie in bash lässt sich der Prompt mit der Variablen `PS1` frei gestalten, am besten in `~/.goshrc`. Ist `PS1` nicht gesetzt, gilt der Standard-Prompt von oben; `unset PS1` holt ihn zurück. `PS2` ist der Prompt für Folgezeilen unvollständiger Befehle (Standard `> `).
```bash
PS1='\u@\h:\w\$ ' # wie der Standard, ohne Farben
PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]\$ ' # mit Farben
PS1='\[\e[2m\]\t\[\e[0m\] \W \$ ' # Uhrzeit und nur der Verzeichnisname
PS1='[$?] \w\n\$ ' # Exit-Status, Eingabe in eigener Zeile
PS1='\w$(git branch --show-current 2>/dev/null | sed "s/.*/ (&)/") \$ ' # git-Branch
PS2='… '
```
| Escape | Bedeutung |
|---------------|-------------------------------------------------------------|
| `\u` | Benutzername |
| `\h` / `\H` | Rechnername bis zum ersten `.` / vollständig |
| `\w` / `\W` | aktuelles Verzeichnis (`~` für das Home-Verzeichnis) / nur der letzte Teil |
| `\$` | `#` für root, sonst `$` |
| `\t` `\T` `\@` `\A` | Uhrzeit: `14:07:09`, `02:07:09`, `02:07 PM`, `14:07` |
| `\d` | Datum, z. B. `Mon Oct 05` |
| `\j` | Anzahl der Jobs |
| `\l` | Terminal (z. B. `pts/3`) |
| `\s` / `\v` / `\V` | `gosh` / Version `0.19` / `0.19.0` |
| `\n` `\r` `\a` `\e` | Zeilenumbruch, Wagenrücklauf, Klingel, Escape |
| `\nnn` | Zeichen mit dem Oktalcode `nnn`, z. B. `\033` |
| `\\` | Backslash |
| `\[` `\]` | umschließen Escape-Sequenzen (wie in bash, siehe unten) |
- Danach werden `$VAR`, `${...}`, `$(...)` und `$((...))` vor jedem Prompt neu ausgewertet. Deshalb `PS1` in einfachen Anführungszeichen setzen, sonst wird z. B. `$(…)` nur einmal beim Zuweisen ausgeführt. `$?` ist der Exit-Status des letzten Befehls; Befehle im Prompt ändern ihn nicht.
- Eine nicht gesetzte Variable im Prompt ist ein Fehler (`gosh: PS1: X: unbound variable`), dann erscheint der Standard-Prompt. `${X:-}` vermeidet das.
- Andere Escapes (z. B. `\D{…}`, `\!`, `\#`) kennt gosh nicht, sie bleiben unverändert stehen.
- `\[` und `\]` braucht gosh nicht, sie werden ignoriert, damit Prompts aus der `.bashrc` unverändert funktionieren. gosh erkennt Farbsequenzen (`\e[…m`) selbst; andere Steuersequenzen wie der Fenstertitel `\e]0;titel\a` werden vor dem Prompt ausgegeben.
- Die Werte von `\w`, `\u`, `\h` usw. und aller Expansionen werden wie der Standard-Prompt bereinigt: Steuerzeichen erscheinen escaped (`\x1b`). Ein Verzeichnis- oder Branch-Name kann so keine Escape-Sequenzen ins Terminal schreiben – Farben müssen deshalb direkt in `PS1` stehen, nicht in der Ausgabe von `$(...)`.
- Wie alle Variablen in gosh ist `PS1` eine Umgebungsvariable und wird an gestartete Programme vererbt (eine bash darin versteht dieselben Escapes).
### Sicherheitshinweise
- Die History liegt in `$XDG_STATE_HOME/gosh/history` (Standard: `~/.local/state/gosh/history`) und ist nur für den eigenen Benutzer lesbar (Verzeichnis `0700`, Datei `0600`). Mit `GOSH_HISTFILE` lässt sich ein anderer Pfad setzen, mit `GOSH_HISTFILE=` wird die History abgeschaltet.
+26 -11
View File
@@ -668,8 +668,13 @@ func prepareHistoryFile(path string) error {
return f.Chmod(0600)
}
// getPrompt returns a dynamic prompt string like "user@host:dir $"
// getPrompt returns the expanded $PS1, or if it is not set the default
// prompt "user@host:dir $".
func getPrompt() string {
if prompt, ok := promptString("PS1"); ok {
return prompt
}
// Get current working directory
wd, err := os.Getwd()
if err != nil {
@@ -690,10 +695,7 @@ func getPrompt() string {
}
// Replace the home directory with ~
home := getHomeDir()
if home != "" && (wd == home || strings.HasPrefix(wd, home+"/")) {
wd = "~" + strings.TrimPrefix(wd, home)
}
wd = tildePath(wd, getHomeDir())
var colors *promptColors
if useColor() {
@@ -961,11 +963,13 @@ func currentWord(s string) (word string, start int, quote byte) {
return b.String(), start, quote
}
// escapeWord escapes characters that would otherwise be interpreted by shlex
// escapeWord escapes characters that would otherwise be interpreted by the
// shell. # and ~ are only special at the start of a word, so they are only
// escaped at the start of s.
func escapeWord(s string) string {
var b strings.Builder
for _, r := range s {
if strings.ContainsRune(" \t\\'\";|&<>*?[${", r) {
for i, r := range s {
if strings.ContainsRune(" \t\\'\"`;|&<>()*?[]{}$!", r) || (i == 0 && (r == '#' || r == '~')) {
b.WriteByte('\\')
}
b.WriteRune(r)
@@ -1369,19 +1373,25 @@ func interactive() {
defer l.Close()
// Main loop. A command that is not complete yet (e.g. if without fi)
// is continued on the next line with the prompt "> ".
// is continued on the next line with the prompt $PS2 (default "> ").
var buf strings.Builder
for {
var prompt string
if buf.Len() == 0 {
runPendingTraps()
// Report finished and stopped background jobs
reapJobs(true)
// Update prompt dynamically (in case directory changed)
reportCwd()
l.SetPrompt(getPrompt())
prompt = getPrompt()
} else if ps2, ok := promptString("PS2"); ok {
prompt = ps2
} else {
l.SetPrompt("> ")
prompt = "> "
}
prefix, prompt := splitPrompt(prompt)
fmt.Print(prefix)
l.SetPrompt(prompt)
line, err := l.Readline()
if err != nil {
@@ -2181,6 +2191,11 @@ Features:
GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values
are ANSI SGR codes, empty = no color); disabled with $NO_COLOR,
TERM=dumb or when output is not a terminal
- Custom prompt with $PS1 (and $PS2 for continuation lines, default
"> ") using the escapes of bash: \u user, \h/\H host, \w/\W directory,
\$ (# for root), \t \T \@ \A \d time/date, \j jobs, \n newline,
\e escape, \[ \]; $VAR, $(...) and $((...)) are expanded, e.g.
PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\w\$ '
- Reports the working directory to the terminal (OSC 7) before each
prompt, so new tabs/windows open in the same directory
- Variables: name=value sets a variable (like set), name=value command
+40
View File
@@ -399,6 +399,7 @@ func TestExecInput_CdDashNoPrevious(t *testing.T) {
// =============================================================================
func TestGetPrompt_Format(t *testing.T) {
unsetPS1(t)
prompt := getPrompt()
// Should contain username@hostname:path $ pattern
@@ -414,6 +415,7 @@ func TestGetPrompt_Format(t *testing.T) {
}
func TestGetPrompt_IncludesHome(t *testing.T) {
unsetPS1(t)
// Save and restore current directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
@@ -877,6 +879,43 @@ func TestShellCompleter_Do(t *testing.T) {
}
}
func TestEscapeWord(t *testing.T) {
tests := []struct{ in, want string }{
{"plain.txt", "plain.txt"},
{"my file (1).txt", `my\ file\ \(1\).txt`},
{"a`b`$c", "a\\`b\\`\\$c"},
{"a;b|c&d<e>f", `a\;b\|c\&d\<e\>f`},
{"*?[x]{y}!", `\*\?\[x\]\{y\}\!`},
{`it's "q"\`, `it\'s\ \"q\"\\`},
{"#a#b", `\#a#b`},
{"~a~b", `\~a~b`},
}
for _, tt := range tests {
if got := escapeWord(tt.in); got != tt.want {
t.Errorf("escapeWord(%q) = %q; want %q", tt.in, got, tt.want)
}
}
}
func TestShellCompleter_SpecialCharacters(t *testing.T) {
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(t.TempDir())
os.Create("song (live) & more.mp3")
got, length := completeLine("ls so")
want := []string{`ng\ \(live\)\ \&\ more.mp3`}
if !reflect.DeepEqual(got, want) || length != 2 {
t.Errorf("Do(%q) = %q, %d; want %q, 2", "ls so", got, length, want)
}
// Inside quotes nothing is escaped
got, _ = completeLine(`ls "so`)
want = []string{"ng (live) & more.mp3"}
if !reflect.DeepEqual(got, want) {
t.Errorf("Do(%q) = %q; want %q", `ls "so`, got, want)
}
}
// =============================================================================
// Security fixes: history file, quoting/expansion, escape sequences, SIGINT
// =============================================================================
@@ -1037,6 +1076,7 @@ func TestSanitize(t *testing.T) {
}
func TestGetPrompt_EscapesControlCharacters(t *testing.T) {
unsetPS1(t)
t.Setenv("NO_COLOR", "1")
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
+66 -3
View File
@@ -445,7 +445,9 @@ and 2 are supported.
.PP
Control structures may span several lines; an incomplete command shows
the continuation prompt
.BR "> " .
.B $PS2
(default
.BR "> " ).
.B !
negates the exit status of a command.
.B for
@@ -731,7 +733,9 @@ Exit the shell.
.PP
Lines starting with a space are not saved in the history.
.PP
The prompt shows
If
.B PS1
is not set, the prompt shows
.IR user @ host : dir
followed by
.BR $ .
@@ -746,6 +750,55 @@ the OSC 7 escape sequence
so terminals like GNOME Terminal open new tabs and windows in the same
directory. Nothing is sent if the output is not a terminal or
.BR TERM=dumb .
.SS Custom prompts
.B PS1
and
.B PS2
are expanded before each prompt. They may contain the backslash escapes
of
.BR bash (1):
.PP
.RS
.nf
\fB\e\&u\fP user name
\fB\e\&h\fP, \fB\e\&H\fP host name up to the first \(oq.\(cq, full host name
\fB\e\&w\fP, \fB\e\&W\fP working directory (\fB~\fP for $HOME), its last component
\fB\e\&$\fP \fB#\fP for root, otherwise \fB$\fP
\fB\e\&t\fP, \fB\e\&T\fP time as HH:MM:SS (24 and 12 hours)
\fB\e\&@\fP, \fB\e\&A\fP time as HH:MM AM/PM and HH:MM (24 hours)
\fB\e\&d\fP date, e.g. Mon Oct 05
\fB\e\&j\fP number of jobs
\fB\e\&l\fP terminal name
\fB\e\&s\fP, \fB\e\&v\fP, \fB\e\&V\fP shell name, version (major.minor), full version
\fB\e\&n\fP, \fB\e\&r\fP newline, carriage return
\fB\e\&a\fP, \fB\e\&e\fP bell, escape
\fB\e\&\fP\fInnn\fP character with octal code \fInnn\fP
\fB\e\e\fP backslash
\fB\e[\fP, \fB\e]\fP ignored (needed by bash around escape sequences)
.fi
.RE
.PP
Unknown escapes are kept as they are. Then
.BI $ VAR\fR,\fP
.BR ${...} ,
.B $(...)
and
.B $((...))
are expanded;
.B $?
is the status of the last command. Control characters in the values of
escapes and expansions are shown escaped, so colors must be written in
the prompt itself, e.g.
.PP
.RS
.nf
PS1=\(aq\e[\ee[1;32m\e]\eu@\eh\e[\ee[0m\e]:\ew\e$ \(aq
.fi
.RE
.PP
If the prompt cannot be expanded (e.g. because of an unset variable), an
error is printed and the default prompt is used. Escape sequences other
than colors (e.g. the window title) are written before the prompt.
.SH ENVIRONMENT
.TP
.B GOSH_HISTFILE
@@ -755,8 +808,18 @@ Path of the history file. If set to an empty value, no history is saved.
Base directory of the default history file
.IR $XDG_STATE_HOME/gosh/history .
.TP
.B PS1
Prompt of the interactive shell, see
.BR "Custom prompts" .
.TP
.B PS2
Prompt for continuation lines (default
.BR "> " ).
.TP
.B GOSH_COLORS
Prompt colors as colon-separated
Colors of the default prompt (if
.B PS1
is not set) as colon-separated
.IB key = code
entries. Keys are
.B user
+270
View File
@@ -0,0 +1,270 @@
package main
// Custom prompts with $PS1 and $PS2, using the backslash escapes of bash:
//
// \u user name \h host name up to the first '.' \H host name
// \w working directory \W its last component (~ for home) \$ # for root, else $
// \t time 24h HH:MM:SS \T 12h HH:MM:SS \@ 12h am/pm \A 24h HH:MM \d date
// \j number of jobs \l terminal \s shell name \v \V version
// \n newline \r carriage return \a bell \e escape \nnn octal \\ backslash
// \[ ... \] begin/end of characters that take no space (escape sequences)
//
// After the escapes, $VAR, ${...}, $(...) and $((...)) are expanded (in the
// same pass, so the values of escapes are not expanded again).
import (
"fmt"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
"time"
)
// promptInfo holds the values for the escapes of a prompt string.
type promptInfo struct {
user, host, wd, home string
root bool
jobs int
tty string
now time.Time
}
// currentPromptInfo collects the promptInfo of the running shell.
func currentPromptInfo() promptInfo {
info := promptInfo{user: "?", host: "unknown", wd: "?", home: getHomeDir(),
root: os.Geteuid() == 0, jobs: len(jobTable), now: time.Now()}
if wd, err := os.Getwd(); err == nil {
info.wd = wd
}
if usr, err := user.Current(); err == nil && usr.Username != "" {
info.user = usr.Username
}
if host, err := os.Hostname(); err == nil {
info.host = host
}
if tty, err := os.Readlink("/proc/self/fd/0"); err == nil && strings.HasPrefix(tty, "/dev/") {
info.tty = filepath.Base(tty)
}
return info
}
// expandPrompt expands the escapes and $ expressions of a PS1 or PS2 value.
// The values of escapes like \w and of expansions are sanitized, so a
// directory or git branch name cannot write escape sequences to the
// terminal; colors have to be written as \e[...m in the prompt itself.
func expandPrompt(ps string, info promptInfo) (string, error) {
// $(...) in the prompt must not change $? or the status of the next
// command line
defer func(status int) { substStatus = status }(substStatus)
var b strings.Builder
for i := 0; i < len(ps); i++ {
ch := ps[i]
if ch == '$' {
value, n, err := expandDollar(ps[i:])
if err != nil {
return "", err
}
b.WriteString(sanitize(value))
i += n - 1
continue
}
if ch != '\\' || i+1 == len(ps) {
b.WriteByte(ch)
continue
}
i++
switch esc := ps[i]; esc {
case 'u':
b.WriteString(sanitize(info.user))
case 'h':
host, _, _ := strings.Cut(info.host, ".")
b.WriteString(sanitize(host))
case 'H':
b.WriteString(sanitize(info.host))
case 'w':
b.WriteString(sanitize(tildePath(info.wd, info.home)))
case 'W':
b.WriteString(sanitize(baseDir(info.wd, info.home)))
case '$':
if info.root {
b.WriteByte('#')
} else {
b.WriteByte('$')
}
case 't':
b.WriteString(info.now.Format("15:04:05"))
case 'T':
b.WriteString(info.now.Format("03:04:05"))
case '@':
b.WriteString(info.now.Format("03:04 PM"))
case 'A':
b.WriteString(info.now.Format("15:04"))
case 'd':
b.WriteString(info.now.Format("Mon Jan 02"))
case 'j':
b.WriteString(strconv.Itoa(info.jobs))
case 'l':
b.WriteString(sanitize(info.tty))
case 's':
b.WriteString("gosh")
case 'v':
b.WriteString(shortVersion(version))
case 'V':
b.WriteString(sanitize(version))
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 'a':
b.WriteByte('\a')
case 'e':
b.WriteByte('\x1b')
case '\\':
b.WriteByte('\\')
case '[', ']':
// Markers for bash's readline. Our readline finds the width of
// the prompt itself (see splitPrompt).
default:
if n := octalLen(ps[i:]); n > 0 {
code, _ := strconv.ParseUint(ps[i:i+n], 8, 8)
b.WriteByte(byte(code))
i += n - 1
} else {
// Unknown escapes are kept, like in bash
b.WriteByte('\\')
b.WriteByte(esc)
}
}
}
return b.String(), nil
}
// tildePath replaces the home directory at the start of path with ~.
func tildePath(path, home string) string {
if home != "" && home != "/" && (path == home || strings.HasPrefix(path, home+"/")) {
return "~" + strings.TrimPrefix(path, home)
}
return path
}
// baseDir returns the last component of path for \W: ~ for the home
// directory, / for the root directory.
func baseDir(path, home string) string {
if home != "" && path == home {
return "~"
}
if path == "/" {
return path
}
return filepath.Base(path)
}
// shortVersion returns major.minor of a version like v0.19.0 for \v.
func shortVersion(v string) string {
v = strings.TrimPrefix(v, "v")
parts := strings.SplitN(v, ".", 3)
if len(parts) >= 2 {
return sanitize(parts[0] + "." + parts[1])
}
return sanitize(v)
}
// octalLen returns the length of the octal number (up to 3 digits, at most
// 0377) at the start of s.
func octalLen(s string) int {
n := 0
for n < len(s) && n < 3 && s[n] >= '0' && s[n] <= '7' {
n++
}
if n == 3 && s[0] > '3' {
n = 2
}
return n
}
// splitPrompt splits an expanded prompt into what is written to the terminal
// before readline starts and the prompt that readline shows. readline only
// handles a single line and only knows the width of SGR sequences
// (\e[...m), so all lines but the last and every other control sequence of
// the last line (e.g. \e]0;title\a for the window title) go into prefix.
func splitPrompt(prompt string) (prefix, line string) {
var pre strings.Builder
if i := strings.LastIndexByte(prompt, '\n'); i >= 0 {
pre.WriteString(prompt[:i+1])
prompt = prompt[i+1:]
}
var b strings.Builder
for i := 0; i < len(prompt); {
ch := prompt[i]
switch {
case ch == '\x1b':
n := escapeLen(prompt[i:])
if seq := prompt[i : i+n]; isSGRSequence(seq) {
b.WriteString(seq)
} else {
pre.WriteString(seq)
}
i += n
case ch < ' ' || ch == 0x7f:
pre.WriteByte(ch)
i++
default:
b.WriteByte(ch)
i++
}
}
return pre.String(), b.String()
}
// escapeLen returns the length of the escape sequence at the start of s
// (which starts with ESC): a CSI sequence ESC [ ... final byte, an OSC
// sequence ESC ] ... BEL or ESC \, or ESC followed by one character.
func escapeLen(s string) int {
if len(s) < 2 {
return len(s)
}
switch s[1] {
case '[':
for i := 2; i < len(s); i++ {
if s[i] >= 0x40 && s[i] <= 0x7e {
return i + 1
}
}
return len(s)
case ']', 'P', '_', '^':
for i := 2; i < len(s); i++ {
if s[i] == '\a' {
return i + 1
}
if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' {
return i + 2
}
}
return len(s)
}
return 2
}
// isSGRSequence reports whether seq is a complete ESC [ ... m sequence.
func isSGRSequence(seq string) bool {
return len(seq) >= 3 && strings.HasPrefix(seq, "\x1b[") && strings.HasSuffix(seq, "m") && isSGR(seq[2:len(seq)-1])
}
// promptString returns the expanded value of the prompt variable name (PS1
// or PS2), or ok == false if it is not set. An error in the prompt is reported and
// the default prompt is used.
func promptString(name string) (string, bool) {
ps, ok := os.LookupEnv(name)
if !ok {
return "", false
}
prompt, err := expandPrompt(ps, currentPromptInfo())
if err != nil {
fmt.Fprintf(os.Stderr, "gosh: %s: %s\n", name, sanitize(err.Error()))
return "", false
}
return prompt, true
}
+125
View File
@@ -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)
}
}