Compare commits

..
2 Commits
Author SHA1 Message Date
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
Florian WaltherandClaude Opus 5.5 af5281eed2 report the working directory to the terminal with OSC 7
Before each prompt gosh sends ESC ] 7 ; file://host/path BEL (host and
path percent-encoded), so terminals like GNOME Terminal open new tabs
and windows in the directory of the shell instead of the one gosh was
started in. Nothing is sent if stdout is not a terminal or TERM=dumb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:22:59 +02:00
6 changed files with 608 additions and 13 deletions

No files matched your search

+45 -2
View File
@@ -59,6 +59,13 @@ Features:
GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values
are ANSI SGR codes, empty = no color); disabled with $NO_COLOR, are ANSI SGR codes, empty = no color); disabled with $NO_COLOR,
TERM=dumb or when output is not a terminal 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 - Variables: name=value sets a variable (like set), name=value command
sets it only for that command; the value is not split or expanded as sets it only for that command; the value is not split or expanded as
a pattern a pattern
@@ -723,7 +730,7 @@ $ bg %2 # oder: angehaltenen Job im Hintergrund weiterlauf
### Prompt ### 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 flow@host:~/src $ false
@@ -739,7 +746,7 @@ Typische Werte: `1` allgemeiner Fehler, `2` Syntaxfehler, `126` nicht ausführba
### Farben ### 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 | | Schlüssel | Teil des Prompts | Standard |
|-----------|--------------------------------|---------------------| |-----------|--------------------------------|---------------------|
@@ -794,6 +801,42 @@ GOSH_COLORS='root=1;97;41'
GOSH_COLORS='user=:path=4' 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 ### 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. - 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.
+67 -8
View File
@@ -668,8 +668,13 @@ func prepareHistoryFile(path string) error {
return f.Chmod(0600) 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 { func getPrompt() string {
if prompt, ok := promptString("PS1"); ok {
return prompt
}
// Get current working directory // Get current working directory
wd, err := os.Getwd() wd, err := os.Getwd()
if err != nil { if err != nil {
@@ -690,10 +695,7 @@ func getPrompt() string {
} }
// Replace the home directory with ~ // Replace the home directory with ~
home := getHomeDir() wd = tildePath(wd, getHomeDir())
if home != "" && (wd == home || strings.HasPrefix(wd, home+"/")) {
wd = "~" + strings.TrimPrefix(wd, home)
}
var colors *promptColors var colors *promptColors
if useColor() { if useColor() {
@@ -795,6 +797,49 @@ func useColor() bool {
return readline.IsTerminal(int(os.Stdout.Fd())) return readline.IsTerminal(int(os.Stdout.Fd()))
} }
// reportCwd tells the terminal the current working directory with the OSC 7
// escape sequence, so that new tabs or windows open in the same directory
// (VTE terminals like GNOME Terminal, kitty, WezTerm, foot, iTerm2, ...).
// Terminals without support ignore it. Nothing is written if stdout is not a
// terminal or TERM is "dumb".
func reportCwd() {
if os.Getenv("TERM") == "dumb" || !readline.IsTerminal(int(os.Stdout.Fd())) {
return
}
wd, err := os.Getwd()
if err != nil {
return
}
hostname, _ := os.Hostname()
fmt.Fprint(os.Stdout, osc7Sequence(hostname, wd))
}
// osc7Sequence returns ESC ] 7 ; file://host/path BEL. Host and path are
// percent-encoded, so control characters in a directory name cannot end the
// sequence early or inject other escape sequences.
func osc7Sequence(hostname, dir string) string {
return "\x1b]7;file://" + percentEncode(hostname, false) + percentEncode(dir, true) + "\a"
}
// percentEncode encodes every byte except unreserved URI characters (and '/'
// if keepSlash) as %XX.
func percentEncode(s string, keepSlash bool) string {
const hex = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
c == '-' || c == '.' || c == '_' || c == '~' || c == '/' && keepSlash {
b.WriteByte(c)
} else {
b.WriteByte('%')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&15])
}
}
return b.String()
}
// shellCompleter implements readline.AutoCompleter for contextual completion // shellCompleter implements readline.AutoCompleter for contextual completion
type shellCompleter struct { type shellCompleter struct {
commands []string commands []string
@@ -1326,18 +1371,25 @@ func interactive() {
defer l.Close() defer l.Close()
// Main loop. A command that is not complete yet (e.g. if without fi) // 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 var buf strings.Builder
for { for {
var prompt string
if buf.Len() == 0 { if buf.Len() == 0 {
runPendingTraps() runPendingTraps()
// Report finished and stopped background jobs // Report finished and stopped background jobs
reapJobs(true) reapJobs(true)
// Update prompt dynamically (in case directory changed) // Update prompt dynamically (in case directory changed)
l.SetPrompt(getPrompt()) reportCwd()
prompt = getPrompt()
} else if ps2, ok := promptString("PS2"); ok {
prompt = ps2
} else { } else {
l.SetPrompt("> ") prompt = "> "
} }
prefix, prompt := splitPrompt(prompt)
fmt.Print(prefix)
l.SetPrompt(prompt)
line, err := l.Readline() line, err := l.Readline()
if err != nil { if err != nil {
@@ -2137,6 +2189,13 @@ Features:
GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values
are ANSI SGR codes, empty = no color); disabled with $NO_COLOR, are ANSI SGR codes, empty = no color); disabled with $NO_COLOR,
TERM=dumb or when output is not a terminal 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 - Variables: name=value sets a variable (like set), name=value command
sets it only for that command; the value is not split or expanded as sets it only for that command; the value is not split or expanded as
a pattern a pattern
+28
View File
@@ -399,6 +399,7 @@ func TestExecInput_CdDashNoPrevious(t *testing.T) {
// ============================================================================= // =============================================================================
func TestGetPrompt_Format(t *testing.T) { func TestGetPrompt_Format(t *testing.T) {
unsetPS1(t)
prompt := getPrompt() prompt := getPrompt()
// Should contain username@hostname:path $ pattern // Should contain username@hostname:path $ pattern
@@ -414,6 +415,7 @@ func TestGetPrompt_Format(t *testing.T) {
} }
func TestGetPrompt_IncludesHome(t *testing.T) { func TestGetPrompt_IncludesHome(t *testing.T) {
unsetPS1(t)
// Save and restore current directory // Save and restore current directory
origDir, _ := os.Getwd() origDir, _ := os.Getwd()
defer os.Chdir(origDir) defer os.Chdir(origDir)
@@ -1037,6 +1039,7 @@ func TestSanitize(t *testing.T) {
} }
func TestGetPrompt_EscapesControlCharacters(t *testing.T) { func TestGetPrompt_EscapesControlCharacters(t *testing.T) {
unsetPS1(t)
t.Setenv("NO_COLOR", "1") t.Setenv("NO_COLOR", "1")
origDir, _ := os.Getwd() origDir, _ := os.Getwd()
defer os.Chdir(origDir) defer os.Chdir(origDir)
@@ -1133,6 +1136,31 @@ func TestBuildPrompt_ColorEscapesControlCharacters(t *testing.T) {
} }
} }
func TestOSC7Sequence(t *testing.T) {
tests := []struct{ host, dir, want string }{
{"host", "/home/flow", "\x1b]7;file://host/home/flow\a"},
{"host", "/tmp/a b/ä", "\x1b]7;file://host/tmp/a%20b/%C3%A4\a"},
{"host", "/x%y#z?", "\x1b]7;file://host/x%25y%23z%3F\a"},
{"", "/", "\x1b]7;file:///\a"},
}
for _, tt := range tests {
if got := osc7Sequence(tt.host, tt.dir); got != tt.want {
t.Errorf("osc7Sequence(%q, %q) = %q, want %q", tt.host, tt.dir, got, tt.want)
}
}
}
func TestOSC7Sequence_EscapesControlCharacters(t *testing.T) {
got := osc7Sequence("ho/st\x07", "/evil\x07\x1b]0;title\x1b\\")
body := strings.TrimSuffix(strings.TrimPrefix(got, "\x1b]7;"), "\a")
if strings.ContainsAny(body, "\x07\x1b") {
t.Errorf("sequence contains control characters: %q", got)
}
if !strings.HasPrefix(body, "file://ho%2Fst%07/evil%07%1B") {
t.Errorf("unexpected encoding: %q", got)
}
}
func TestUseColor_NoColor(t *testing.T) { func TestUseColor_NoColor(t *testing.T) {
t.Setenv("NO_COLOR", "1") t.Setenv("NO_COLOR", "1")
if useColor() { if useColor() {
+73 -3
View File
@@ -445,7 +445,9 @@ and 2 are supported.
.PP .PP
Control structures may span several lines; an incomplete command shows Control structures may span several lines; an incomplete command shows
the continuation prompt the continuation prompt
.BR "> " . .B $PS2
(default
.BR "> " ).
.B ! .B !
negates the exit status of a command. negates the exit status of a command.
.B for .B for
@@ -731,7 +733,9 @@ Exit the shell.
.PP .PP
Lines starting with a space are not saved in the history. Lines starting with a space are not saved in the history.
.PP .PP
The prompt shows If
.B PS1
is not set, the prompt shows
.IR user @ host : dir .IR user @ host : dir
followed by followed by
.BR $ . .BR $ .
@@ -739,6 +743,62 @@ If the last command failed, its exit status is shown as
.BI [ N ] .BI [ N ]
before the before the
.BR $ . .BR $ .
.PP
Before each prompt, gosh reports the working directory to the terminal with
the OSC 7 escape sequence
.RB ( "ESC ] 7 ; file://" \fIhost\fP/\fIpath\fP " BEL" ),
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 .SH ENVIRONMENT
.TP .TP
.B GOSH_HISTFILE .B GOSH_HISTFILE
@@ -748,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 Base directory of the default history file
.IR $XDG_STATE_HOME/gosh/history . .IR $XDG_STATE_HOME/gosh/history .
.TP .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 .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 .IB key = code
entries. Keys are entries. Keys are
.B user .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)
}
}