Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb8653c9f5 | ||
|
|
82f9c90a39 |
No files matched your search
@@ -59,6 +59,11 @@ 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
|
- Reports the working directory to the terminal (OSC 7) before each
|
||||||
prompt, so new tabs/windows open in the same directory
|
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
|
||||||
@@ -725,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
|
||||||
@@ -741,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 |
|
||||||
|-----------|--------------------------------|---------------------|
|
|-----------|--------------------------------|---------------------|
|
||||||
@@ -796,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.
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -961,11 +963,13 @@ func currentWord(s string) (word string, start int, quote byte) {
|
|||||||
return b.String(), start, quote
|
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 {
|
func escapeWord(s string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, r := range s {
|
for i, r := range s {
|
||||||
if strings.ContainsRune(" \t\\'\";|&<>*?[${", r) {
|
if strings.ContainsRune(" \t\\'\"`;|&<>()*?[]{}$!", r) || (i == 0 && (r == '#' || r == '~')) {
|
||||||
b.WriteByte('\\')
|
b.WriteByte('\\')
|
||||||
}
|
}
|
||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
@@ -1369,19 +1373,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)
|
||||||
reportCwd()
|
reportCwd()
|
||||||
l.SetPrompt(getPrompt())
|
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 {
|
||||||
@@ -2181,6 +2191,11 @@ 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
|
- Reports the working directory to the terminal (OSC 7) before each
|
||||||
prompt, so new tabs/windows open in the same directory
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
// Security fixes: history file, quoting/expansion, escape sequences, SIGINT
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -1037,6 +1076,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)
|
||||||
|
|||||||
+66
-3
@@ -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 $ .
|
||||||
@@ -746,6 +750,55 @@ the OSC 7 escape sequence
|
|||||||
so terminals like GNOME Terminal open new tabs and windows in the same
|
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
|
directory. Nothing is sent if the output is not a terminal or
|
||||||
.BR TERM=dumb .
|
.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
|
||||||
@@ -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
|
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
|
||||||
|
|||||||
@@ -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
@@ -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