diff --git a/README.md b/README.md index 1c8b99b..f645170 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,67 @@ Features: - Tab completion for commands and file paths - Tilde expansion (~, ~/path) at the start of unquoted words - Colored prompt (user@host green, red for root; directory blue), + configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36' + (keys user, root, path; values are ANSI SGR codes, empty = no color); disabled with $NO_COLOR, TERM=dumb or when output is not a terminal - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); using an unset variable is an error, use ${VAR:-} for an empty default ``` +### Farben + +Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`: + +| Schlüssel | Teil des Prompts | Standard | +|-----------|--------------------------------|---------------------| +| `user` | `user@host` | `1;32` (fett grün) | +| `root` | `user@host`, wenn man root ist | `1;31` (fett rot) | +| `path` | aktuelles Verzeichnis | `1;34` (fett blau) | + +Die Werte sind [ANSI-SGR-Codes](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters), mehrere werden mit `;` kombiniert: + +- `1` fett, `2` blass, `3` kursiv, `4` unterstrichen +- `30`–`37` Vordergrund, `90`–`97` helle Vordergrundfarben, `40`–`47` Hintergrund +- `38;5;N` eine der 256 Farben, `38;2;R;G;B` Truecolor (sofern das Terminal es kann) + +Nicht angegebene Schlüssel behalten ihre Standardfarbe, ein leerer Wert (`path=`) schaltet die Farbe für diesen Teil ab. Ungültige Werte werden ignoriert. Da nur Ziffern und `;` erlaubt sind, lassen sich über die Variable keine beliebigen Escape-Sequenzen ins Terminal schreiben. `NO_COLOR` schaltet alle Farben ab. + +Gesetzt wird die Variable z. B. in `~/.profile` oder direkt in gosh (wirkt ab dem nächsten Prompt): + +```bash +export GOSH_COLORS='user=1;33:path=1;36' # in ~/.profile +set 'GOSH_COLORS=user=1;33:path=1;36' # in gosh +``` + +Ein paar Kombinationen zum Ausprobieren: + +```bash +# Solarized: gelb und cyan +GOSH_COLORS='user=33:path=36' + +# Sonnenuntergang: orange und pink (256 Farben) +GOSH_COLORS='user=1;38;5;208:path=38;5;205' + +# Matrix: hellgrün und blassgrün +GOSH_COLORS='user=1;92:path=2;32' + +# Dracula: lila und pink (Truecolor) +GOSH_COLORS='user=1;38;2;189;147;249:path=38;2;255;121;198' + +# Nord: frostblau und hellgrau (Truecolor) +GOSH_COLORS='user=1;38;2;136;192;208:path=38;2;216;222;233' + +# Ozean: türkis und kursiv blau +GOSH_COLORS='user=1;96:path=3;94' + +# Root-Warnung: weiß auf rotem Hintergrund +GOSH_COLORS='root=1;97;41' + +# Dezent: nur das Verzeichnis unterstrichen +GOSH_COLORS='user=:path=4' +``` + ### 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 6d510b3..2c29a93 100644 --- a/main.go +++ b/main.go @@ -309,31 +309,82 @@ func getPrompt() string { wd = "~" + strings.TrimPrefix(wd, home) } - return buildPrompt(username, hostname, wd, os.Geteuid() == 0, useColor()) + var colors *promptColors + if useColor() { + c := parseColors(os.Getenv("GOSH_COLORS")) + colors = &c + } + return buildPrompt(username, hostname, wd, os.Geteuid() == 0, colors) } -// ANSI escape sequences used for the prompt -const ( - colorReset = "\x1b[0m" - colorUser = "\x1b[1;32m" // bold green - colorRoot = "\x1b[1;31m" // bold red - colorPath = "\x1b[1;34m" // bold blue -) +// promptColors holds the SGR parameters (e.g. "1;32") for the parts of the +// prompt. An empty value means no color for that part. +type promptColors struct { + user string // user@host + root string // user@host when running as root + path string // working directory +} + +// defaultColors: bold green user, bold red root, bold blue directory +var defaultColors = promptColors{user: "1;32", root: "1;31", path: "1;34"} + +// parseColors reads a GOSH_COLORS value like "user=1;33:root=1;31:path=36" +// and overrides the corresponding defaults. Unknown keys and values that are +// not plain SGR parameters (digits and ';') are ignored, so the variable +// cannot be used to write arbitrary escape sequences to the terminal. +func parseColors(spec string) promptColors { + colors := defaultColors + for _, entry := range strings.Split(spec, ":") { + key, value, ok := strings.Cut(entry, "=") + if !ok || !isSGR(value) { + continue + } + switch key { + case "user": + colors.user = value + case "root": + colors.root = value + case "path": + colors.path = value + } + } + return colors +} + +// isSGR reports whether s consists only of digits and ';'. The empty string +// is allowed and disables the color. +func isSGR(s string) bool { + for _, r := range s { + if (r < '0' || r > '9') && r != ';' { + return false + } + } + return true +} + +// colorize wraps text in the SGR sequence for sgr, or returns it unchanged if +// sgr is empty. +func colorize(text, sgr string) string { + if sgr == "" { + return text + } + return "\x1b[" + sgr + "m" + text + "\x1b[0m" +} // buildPrompt formats the prompt as user@host:path $. The parts are sanitized // before the color codes are added, so only our own escape sequences end up -// in the prompt. -func buildPrompt(username, hostname, path string, root, color bool) string { +// in the prompt. colors == nil disables colors. +func buildPrompt(username, hostname, path string, root bool, colors *promptColors) string { userHost := sanitize(username + "@" + hostname) path = sanitize(path) - if !color { + if colors == nil { return userHost + ":" + path + " $ " } - userColor := colorUser + userColor := colors.user if root { - userColor = colorRoot + userColor = colors.root } - return userColor + userHost + colorReset + ":" + colorPath + path + colorReset + " $ " + return colorize(userHost, userColor) + ":" + colorize(path, colors.path) + " $ " } // useColor reports whether the prompt should be colored: stdout must be a @@ -698,6 +749,8 @@ Features: - Tab completion for commands and file paths - Tilde expansion (~, ~/path) at the start of unquoted words - Colored prompt (user@host green, red for root; directory blue), + configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36' + (keys user, root, path; values are ANSI SGR codes, empty = no color); disabled with $NO_COLOR, TERM=dumb or when output is not a terminal - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); using an unset variable is an error, use ${VAR:-} for an empty default`) diff --git a/main_test.go b/main_test.go index 95a61ef..b14220a 100644 --- a/main_test.go +++ b/main_test.go @@ -1026,25 +1026,52 @@ func TestGetPrompt_EscapesControlCharacters(t *testing.T) { } func TestBuildPrompt_Color(t *testing.T) { - got := buildPrompt("flow", "host", "~/src", false, true) - want := colorUser + "flow@host" + colorReset + ":" + colorPath + "~/src" + colorReset + " $ " + got := buildPrompt("flow", "host", "~/src", false, &defaultColors) + want := "\x1b[1;32mflow@host\x1b[0m:\x1b[1;34m~/src\x1b[0m $ " if got != want { t.Errorf("buildPrompt() = %q, want %q", got, want) } - got = buildPrompt("root", "host", "/", true, true) - if !strings.HasPrefix(got, colorRoot+"root@host") { + got = buildPrompt("root", "host", "/", true, &defaultColors) + if !strings.HasPrefix(got, "\x1b[1;31mroot@host") { t.Errorf("expected red user for root, got %q", got) } - got = buildPrompt("flow", "host", "~/src", false, false) + got = buildPrompt("flow", "host", "~/src", false, nil) if got != "flow@host:~/src $ " { t.Errorf("buildPrompt() without color = %q", got) } + + colors := promptColors{user: "38;5;208", path: ""} + got = buildPrompt("flow", "host", "~/src", false, &colors) + if got != "\x1b[38;5;208mflow@host\x1b[0m:~/src $ " { + t.Errorf("buildPrompt() with custom colors = %q", got) + } +} + +func TestParseColors(t *testing.T) { + tests := []struct { + spec string + want promptColors + }{ + {"", defaultColors}, + {"user=1;33", promptColors{user: "1;33", root: "1;31", path: "1;34"}}, + {"user=33:root=41;97:path=38;5;208", promptColors{user: "33", root: "41;97", path: "38;5;208"}}, + {"path=", promptColors{user: "1;32", root: "1;31", path: ""}}, + {"path=0m\x1b[2J", defaultColors}, // escape sequences are rejected + {"path=red", defaultColors}, // only numeric codes + {"foo=1:user", defaultColors}, // unknown key, missing value + {"user=35::path=36", promptColors{user: "35", root: "1;31", path: "36"}}, + } + for _, tc := range tests { + if got := parseColors(tc.spec); got != tc.want { + t.Errorf("parseColors(%q) = %+v, want %+v", tc.spec, got, tc.want) + } + } } func TestBuildPrompt_ColorEscapesControlCharacters(t *testing.T) { - got := buildPrompt("flow", "host", "evil\x1b[2J\x07", false, true) + got := buildPrompt("flow", "host", "evil\x1b[2J\x07", false, &defaultColors) if strings.Contains(got, "\x1b[2J") || strings.Contains(got, "\x07") { t.Errorf("prompt contains control characters from path: %q", got) }