From 82f9c90a39ac7f77aa2c8810a99973c7f021f5cc Mon Sep 17 00:00:00 2001 From: Florian Walther Date: Mon, 5 Oct 2026 22:49:19 +0200 Subject: [PATCH] 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 --- README.md | 45 +++++++- main.go | 29 +++-- main_test.go | 3 + packaging/man/gosh.1 | 69 ++++++++++- prompt.go | 270 +++++++++++++++++++++++++++++++++++++++++++ prompt_test.go | 125 ++++++++++++++++++++ 6 files changed, 528 insertions(+), 13 deletions(-) create mode 100644 prompt.go create mode 100644 prompt_test.go diff --git a/README.md b/README.md index 2ebc041..73174fe 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/main.go b/main.go index 1f4bbf8..9befd27 100644 --- a/main.go +++ b/main.go @@ -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() { @@ -1369,19 +1371,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 +2189,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 diff --git a/main_test.go b/main_test.go index 419240d..2539696 100644 --- a/main_test.go +++ b/main_test.go @@ -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) @@ -1037,6 +1039,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) diff --git a/packaging/man/gosh.1 b/packaging/man/gosh.1 index 53d9003..7a5e562 100644 --- a/packaging/man/gosh.1 +++ b/packaging/man/gosh.1 @@ -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 diff --git a/prompt.go b/prompt.go new file mode 100644 index 0000000..6bacf57 --- /dev/null +++ b/prompt.go @@ -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 +} diff --git a/prompt_test.go b/prompt_test.go new file mode 100644 index 0000000..24d685e --- /dev/null +++ b/prompt_test.go @@ -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) + } +}