Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e49fe6fe11 | ||
|
|
041406a340 | ||
|
|
b3311cbf94 | ||
|
|
da281ca4ab |
No files matched your search
@@ -30,15 +30,22 @@ Features:
|
||||
override with $GOSH_HISTFILE); lines starting with a space are not saved
|
||||
- Tab completion for commands and file paths
|
||||
- Tilde expansion (~, ~/path) at the start of unquoted words
|
||||
- Brace expansion: a{b,c}d -> abd acd, {1..5}, {01..10..2}, {a..e}
|
||||
- Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files
|
||||
unless the pattern starts with a dot); quoted wildcards and values of
|
||||
variables are not expanded, a pattern without match is kept as is
|
||||
- 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
|
||||
- Prompt user@host:dir $, with [N] before the $ if the last command
|
||||
failed with exit status N
|
||||
- Colored prompt (user@host green, red for root; directory blue; exit
|
||||
status red), configurable with $GOSH_COLORS, e.g.
|
||||
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
|
||||
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
|
||||
using an unset variable is an error, use ${VAR:-} for an empty default
|
||||
- Command substitution: $(command) is replaced by the output of command
|
||||
(run in a subshell); unquoted it is split into words, "$(command)"
|
||||
stays one word
|
||||
- Command lists: a ; b (run both), a && b (b only if a succeeded),
|
||||
a || b (b only if a failed); $? is the exit status of the last command
|
||||
- Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
|
||||
@@ -118,6 +125,26 @@ export LANG=de_DE.UTF-8
|
||||
export PATH=$HOME/bin:$PATH
|
||||
```
|
||||
|
||||
### Befehlsersetzung
|
||||
|
||||
`$(befehl)` wird durch die Ausgabe von `befehl` ersetzt:
|
||||
|
||||
```
|
||||
$ echo "Heute ist $(date +%A)"
|
||||
Heute ist Montag
|
||||
$ kill $(pgrep -f mein-server)
|
||||
$ cd $(git rev-parse --show-toplevel)
|
||||
$ vim $(grep -l TODO *.go)
|
||||
$ echo "Dateien: $(ls | wc -l)"
|
||||
```
|
||||
|
||||
- Der Befehl läuft in einer eigenen Subshell (einem neuen gosh-Prozess): `cd`, `set` oder `export` darin ändern die laufende Shell nicht. Er darf alles enthalten, was gosh kann (Pipes, `;`, `&&`, Umleitungen, verschachtelte `$(...)`), und liest von der gleichen Eingabe, Fehlermeldungen gehen auf stderr.
|
||||
- Abschließende Zeilenumbrüche der Ausgabe werden entfernt.
|
||||
- Ohne Anführungszeichen wird die Ausgabe an Leerzeichen, Tabs und Zeilenumbrüchen in mehrere Wörter geteilt, `"$(befehl)"` bleibt ein einziges Wort (mit den Zeilenumbrüchen darin).
|
||||
- Anders als in `sh` werden Wildcards in der Ausgabe nicht expandiert, und Zeichen wie `;` oder `|` darin sind normaler Text: Die Ausgabe ist immer nur Daten.
|
||||
- Besteht ein Befehl nur aus einer Ersetzung (`$(false)`), ist ihr Exit-Status `$?`, sonst zählt der Status des Befehls. Ctrl+C während der Ersetzung bricht die ganze Zeile ab.
|
||||
- In `'...'` oder mit `\$(` wird nichts ersetzt. Die alte Schreibweise mit Backticks (`` `befehl` ``) und Arithmetik `$((1+2))` gibt es nicht.
|
||||
|
||||
### Befehlslisten und Exit-Status
|
||||
|
||||
Mehrere Befehle in einer Zeile werden wie in `sh` verknüpft:
|
||||
@@ -142,6 +169,35 @@ Status 127
|
||||
|
||||
Schlägt ein Programm fehl, gibt gosh selbst keine Meldung mehr aus (das Programm meldet den Fehler selbst), nur bei Fehlern von gosh, z. B. wenn ein Befehl nicht gefunden wird.
|
||||
|
||||
### Klammer-Expansion
|
||||
|
||||
Wie in bash erzeugt `{…}` aus einem Wort mehrere Wörter:
|
||||
|
||||
| Ausdruck | ergibt |
|
||||
|-------------------|---------------------------------|
|
||||
| `a{b,c}d` | `abd acd` |
|
||||
| `file{,.bak}` | `file file.bak` |
|
||||
| `{a,b}{1,2}` | `a1 a2 b1 b2` |
|
||||
| `x{a,{b,c}}` | `xa xb xc` (verschachtelt) |
|
||||
| `{1..5}` | `1 2 3 4 5` |
|
||||
| `{5..1}` | `5 4 3 2 1` |
|
||||
| `{1..10..3}` | `1 4 7 10` (Schrittweite) |
|
||||
| `{01..10..4}` | `01 05 09` (mit führenden Nullen) |
|
||||
| `{a..e}` | `a b c d e` |
|
||||
|
||||
```
|
||||
$ mkdir -p projekt/{src,doc,test}
|
||||
$ cp config.yaml{,.bak} # Sicherungskopie
|
||||
$ ls *.{go,md}
|
||||
$ touch log-{01..12}.txt
|
||||
```
|
||||
|
||||
- Die Klammer-Expansion kommt zuerst, die Ergebnisse durchlaufen danach `~`, Variablen und Wildcards: `*.{go,md}` wird zu `*.go *.md`, `{${A},b}` zu `$A b`.
|
||||
- Ohne Komma (und ohne gültige Folge) bleiben Klammern stehen: `{a}`, `{}`, `{a..1}`. Auch `${VAR}` wird nicht angefasst.
|
||||
- In Anführungszeichen oder mit `\` davor sind die Zeichen normale Zeichen: `"{a,b}"`, `\{a,b\}`. Ein Komma lässt sich mit `\,` schützen: `{a\,b,c}` ergibt `a,b c`.
|
||||
- Bei einer Umleitung muss genau ein Wort herauskommen, `> {a,b}` meldet `ambiguous redirect`.
|
||||
- Ein Wort darf höchstens 100 000 Wörter erzeugen.
|
||||
|
||||
### Wildcards
|
||||
|
||||
Wörter mit `*`, `?` oder `[...]` werden durch die passenden Dateinamen ersetzt, sortiert:
|
||||
@@ -256,8 +312,25 @@ $ bg %2 # oder: angehaltenen Job im Hintergrund weiterlauf
|
||||
- `exit` (oder Ctrl+D) mit angehaltenen Jobs warnt einmal (`there are stopped jobs`), ein zweites `exit` direkt danach beendet gosh.
|
||||
- `cd`, `set`, `export`, `exit`, `source`, `fg`, `bg` und `wait` lassen sich nicht mit `&` im Hintergrund starten.
|
||||
- In Skripten und mit `gosh -c` gibt es keine Job-Control (kein Ctrl+Z, kein `fg`/`bg`), `&` und `wait` funktionieren aber. Hintergrund-Jobs lesen dort von `/dev/null`.
|
||||
- Wird ein angehaltener Job von außen fortgesetzt (`kill -CONT`), zeigt `jobs` ihn weiter als `Stopped`, bis er endet oder erneut angehalten wird; `fg` und `bg` funktionieren trotzdem.
|
||||
- Einen Job beenden lässt sich mit `kill` über die Prozess-ID (z. B. `kill 12345`); `kill %1` versteht gosh noch nicht.
|
||||
|
||||
### 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:
|
||||
|
||||
```
|
||||
flow@host:~/src $ false
|
||||
flow@host:~/src [1] $ gibtsnicht
|
||||
gosh: gibtsnicht: command not found
|
||||
flow@host:~/src [127] $ sleep 100 # mit Ctrl+C abgebrochen
|
||||
^C
|
||||
flow@host:~/src [130] $ true
|
||||
flow@host:~/src $
|
||||
```
|
||||
|
||||
Typische Werte: `1` allgemeiner Fehler, `2` Syntaxfehler, `126` nicht ausführbar, `127` Befehl nicht gefunden, `128+n` durch Signal `n` beendet (`130` = Ctrl+C, `148` = mit Ctrl+Z angehalten unter Linux). Eine leere Eingabe ändert den Status nicht.
|
||||
|
||||
### Farben
|
||||
|
||||
Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`:
|
||||
@@ -267,6 +340,7 @@ Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie beste
|
||||
| `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) |
|
||||
| `error` | Exit-Status `[N]` nach einem Fehler | `1;31` (fett rot) |
|
||||
|
||||
Die Werte sind [ANSI-SGR-Codes](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters), mehrere werden mit `;` kombiniert:
|
||||
|
||||
@@ -304,6 +378,9 @@ 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'
|
||||
|
||||
# Fehler-Status gelb auf rotem Hintergrund
|
||||
GOSH_COLORS='error=1;33;41'
|
||||
|
||||
# Root-Warnung: weiß auf rotem Hintergrund
|
||||
GOSH_COLORS='root=1;97;41'
|
||||
|
||||
|
||||
@@ -96,6 +96,16 @@ func expandAliasesIn(line string, active map[string]bool) string {
|
||||
}
|
||||
}
|
||||
|
||||
if quote != '\'' && !escaped && isSubstStart(line, i) {
|
||||
// $(...) is copied as it is; the subshell does not expand
|
||||
// aliases
|
||||
if end := substEnd(line, i); end >= 0 {
|
||||
out.WriteString(line[i : end+1])
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
out.WriteByte(ch)
|
||||
switch {
|
||||
case escaped:
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
package main
|
||||
|
||||
// Brace expansion like in bash: a{b,c}d becomes abd acd, {1..5} becomes
|
||||
// 1 2 3 4 5. It happens first, on the text of the command line, so that the
|
||||
// resulting words go through the other expansions (~, variables, wildcards).
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxBraceWords limits the number of words a single word may expand to.
|
||||
const maxBraceWords = 100000
|
||||
|
||||
// expandBraceWords applies brace expansion to every word of a command (the
|
||||
// text of a pipeline). Quoted or escaped braces, ${...} and $(...) are left
|
||||
// alone.
|
||||
// A redirection target must expand to a single word.
|
||||
func expandBraceWords(input string) (string, error) {
|
||||
var (
|
||||
out strings.Builder
|
||||
afterRedirect bool // the next word is the target of a redirection
|
||||
)
|
||||
|
||||
for i := 0; i < len(input); {
|
||||
ch := input[i]
|
||||
switch {
|
||||
case ch == ' ' || ch == '\t' || ch == '\n':
|
||||
out.WriteByte(ch)
|
||||
i++
|
||||
case strings.IndexByte(";&|<>", ch) >= 0:
|
||||
if ch == '<' || ch == '>' {
|
||||
afterRedirect = true
|
||||
}
|
||||
out.WriteByte(ch)
|
||||
i++
|
||||
case ch == '#':
|
||||
// A comment, until the end of the line
|
||||
out.WriteString(input[i:])
|
||||
return out.String(), nil
|
||||
default:
|
||||
end := wordEnd(input, i)
|
||||
words, err := expandBraces(input[i:end])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if afterRedirect && len(words) > 1 {
|
||||
return "", fmt.Errorf("%s: ambiguous redirect", sanitize(input[i:end]))
|
||||
}
|
||||
afterRedirect = false
|
||||
out.WriteString(strings.Join(words, " "))
|
||||
i = end
|
||||
}
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// wordEnd returns the end of the word starting at i: the next unquoted blank
|
||||
// or operator character.
|
||||
func wordEnd(s string, i int) int {
|
||||
var quote byte
|
||||
for ; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
switch {
|
||||
case quote == '\'':
|
||||
if ch == '\'' {
|
||||
quote = 0
|
||||
}
|
||||
case ch == '\\':
|
||||
i++
|
||||
case isSubstStart(s, i):
|
||||
if j := substEnd(s, i); j >= 0 {
|
||||
i = j
|
||||
}
|
||||
case ch == '$' && i+1 < len(s) && s[i+1] == '{':
|
||||
if j := strings.IndexByte(s[i:], '}'); j >= 0 {
|
||||
i += j
|
||||
}
|
||||
case quote == '"':
|
||||
if ch == '"' {
|
||||
quote = 0
|
||||
}
|
||||
case ch == '"' || ch == '\'':
|
||||
quote = ch
|
||||
case strings.IndexByte(" \t\n;&|<>", ch) >= 0:
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
|
||||
// braceScan calls fn for every unquoted, unescaped character of s that is
|
||||
// not part of a ${...} reference, with its index and the brace depth.
|
||||
// Scanning stops when fn returns false.
|
||||
func braceScan(s string, fn func(i, depth int) bool) {
|
||||
var quote byte
|
||||
depth := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
switch {
|
||||
case quote == '\'':
|
||||
if ch == '\'' {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
case ch == '\\':
|
||||
i++
|
||||
continue
|
||||
case isSubstStart(s, i):
|
||||
// The subshell does its own brace expansion
|
||||
if j := substEnd(s, i); j >= 0 {
|
||||
i = j
|
||||
}
|
||||
continue
|
||||
case ch == '$' && i+1 < len(s) && s[i+1] == '{':
|
||||
if j := strings.IndexByte(s[i:], '}'); j >= 0 {
|
||||
i += j
|
||||
}
|
||||
continue
|
||||
case quote == '"':
|
||||
if ch == '"' {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
case ch == '"' || ch == '\'':
|
||||
quote = ch
|
||||
continue
|
||||
}
|
||||
if ch == '}' {
|
||||
depth--
|
||||
}
|
||||
if !fn(i, depth) {
|
||||
return
|
||||
}
|
||||
if ch == '{' {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expandBraces expands the first valid brace expression of a word and then
|
||||
// recursively the results: a{b,c}d -> abd acd, x{1..3} -> x1 x2 x3. A brace
|
||||
// expression is valid if it contains an unquoted comma at its top level or
|
||||
// is a sequence; other braces ({}, {a}, ${x}) are kept as they are.
|
||||
func expandBraces(word string) ([]string, error) {
|
||||
// Find the candidates: unquoted { with their matching }
|
||||
var opens []int
|
||||
pairs := map[int]int{}
|
||||
braceScan(word, func(i, depth int) bool {
|
||||
switch word[i] {
|
||||
case '{':
|
||||
opens = append(opens, i)
|
||||
case '}':
|
||||
if len(opens) > 0 {
|
||||
pairs[opens[len(opens)-1]] = i
|
||||
opens = opens[:len(opens)-1]
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
for start := 0; start < len(word); start++ {
|
||||
end, ok := pairs[start]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
inner := word[start+1 : end]
|
||||
alternatives := splitTopLevelCommas(inner)
|
||||
if len(alternatives) < 2 {
|
||||
seq, ok, err := braceSequence(inner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
alternatives = seq
|
||||
}
|
||||
|
||||
prefix, suffix := word[:start], word[end+1:]
|
||||
var words []string
|
||||
for _, alt := range alternatives {
|
||||
expanded, err := expandBraces(prefix + alt + suffix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
words = append(words, expanded...)
|
||||
if len(words) > maxBraceWords {
|
||||
return nil, errors.New("brace expansion: too many words")
|
||||
}
|
||||
}
|
||||
return words, nil
|
||||
}
|
||||
return []string{word}, nil
|
||||
}
|
||||
|
||||
// splitTopLevelCommas splits s at its unquoted commas that are not inside
|
||||
// nested braces.
|
||||
func splitTopLevelCommas(s string) []string {
|
||||
var parts []string
|
||||
last := 0
|
||||
braceScan(s, func(i, depth int) bool {
|
||||
if s[i] == ',' && depth == 0 {
|
||||
parts = append(parts, s[last:i])
|
||||
last = i + 1
|
||||
}
|
||||
return true
|
||||
})
|
||||
return append(parts, s[last:])
|
||||
}
|
||||
|
||||
// braceSequence expands a sequence expression: x..y or x..y..step with
|
||||
// integers (zero-padded to the same width if one of them has a leading zero)
|
||||
// or single letters. ok is false if s is no sequence.
|
||||
func braceSequence(s string) (words []string, ok bool, err error) {
|
||||
parts := strings.Split(s, "..")
|
||||
if len(parts) != 2 && len(parts) != 3 {
|
||||
return nil, false, nil
|
||||
}
|
||||
step := 1
|
||||
if len(parts) == 3 {
|
||||
step, err = strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
if step < 0 {
|
||||
step = -step
|
||||
}
|
||||
if step == 0 {
|
||||
step = 1
|
||||
}
|
||||
}
|
||||
|
||||
from, errFrom := strconv.Atoi(parts[0])
|
||||
to, errTo := strconv.Atoi(parts[1])
|
||||
isLetter := func(p string) bool {
|
||||
return len(p) == 1 && (p[0] >= 'a' && p[0] <= 'z' || p[0] >= 'A' && p[0] <= 'Z')
|
||||
}
|
||||
format := func(n int) string { return strconv.Itoa(n) }
|
||||
switch {
|
||||
case errFrom == nil && errTo == nil:
|
||||
// {01..10}: pad to the width of the longer number
|
||||
if hasLeadingZero(parts[0]) || hasLeadingZero(parts[1]) {
|
||||
width := max(len(parts[0]), len(parts[1]))
|
||||
format = func(n int) string {
|
||||
if n < 0 {
|
||||
return fmt.Sprintf("-%0*d", width-1, -n)
|
||||
}
|
||||
return fmt.Sprintf("%0*d", width, n)
|
||||
}
|
||||
}
|
||||
case isLetter(parts[0]) && isLetter(parts[1]):
|
||||
from, to = int(parts[0][0]), int(parts[1][0])
|
||||
format = func(n int) string { return string(rune(n)) }
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
count := (max(from, to)-min(from, to))/step + 1
|
||||
if count > maxBraceWords {
|
||||
return nil, false, errors.New("brace expansion: too many words")
|
||||
}
|
||||
if to < from {
|
||||
step = -step
|
||||
}
|
||||
for n := from; (step > 0 && n <= to) || (step < 0 && n >= to); n += step {
|
||||
words = append(words, format(n))
|
||||
}
|
||||
return words, true, nil
|
||||
}
|
||||
|
||||
// hasLeadingZero reports whether a number is written with a leading zero
|
||||
// (01, -05), which makes a sequence zero-padded.
|
||||
func hasLeadingZero(s string) bool {
|
||||
s = strings.TrimPrefix(s, "-")
|
||||
return len(s) > 1 && s[0] == '0'
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpandBraces(t *testing.T) {
|
||||
tests := []struct {
|
||||
word string
|
||||
want []string
|
||||
}{
|
||||
{"a{b,c}d", []string{"abd", "acd"}},
|
||||
{"{a,b}{1,2}", []string{"a1", "a2", "b1", "b2"}},
|
||||
{"x{a,{b,c}}y", []string{"xay", "xby", "xcy"}},
|
||||
{"file{,.bak}", []string{"file", "file.bak"}},
|
||||
{"{1..5}", []string{"1", "2", "3", "4", "5"}},
|
||||
{"{3..1}", []string{"3", "2", "1"}},
|
||||
{"{1..10..3}", []string{"1", "4", "7", "10"}},
|
||||
{"{10..1..-4}", []string{"10", "6", "2"}},
|
||||
{"{01..10..4}", []string{"01", "05", "09"}},
|
||||
{"{8..010..1}", []string{"008", "009", "010"}},
|
||||
{"{-2..2}", []string{"-2", "-1", "0", "1", "2"}},
|
||||
{"{-01..1}", []string{"-01", "000", "001"}},
|
||||
{"{a..e}", []string{"a", "b", "c", "d", "e"}},
|
||||
{"{C..A}", []string{"C", "B", "A"}},
|
||||
{"*.{go,md}", []string{"*.go", "*.md"}},
|
||||
{`{"a b",c}`, []string{`"a b"`, "c"}},
|
||||
{`{a\,b,c}`, []string{`a\,b`, "c"}},
|
||||
{"{$HOME,~}/x", []string{"$HOME/x", "~/x"}},
|
||||
// Not expanded: quoted, escaped, no comma, ${...}, invalid sequences
|
||||
{`"{a,b}"`, []string{`"{a,b}"`}},
|
||||
{`'{a,b}'`, []string{`'{a,b}'`}},
|
||||
{`\{a,b\}`, []string{`\{a,b\}`}},
|
||||
{"{a}", []string{"{a}"}},
|
||||
{"{}", []string{"{}"}},
|
||||
{"{a,b", []string{"{a,b"}},
|
||||
{"a,b}", []string{"a,b}"}},
|
||||
{"${HOME}", []string{"${HOME}"}},
|
||||
{"${X:-a,b}", []string{"${X:-a,b}"}},
|
||||
{"{a..1}", []string{"{a..1}"}},
|
||||
{"{1..2..x}", []string{"{1..2..x}"}},
|
||||
{"{ab..cd}", []string{"{ab..cd}"}},
|
||||
// An invalid brace before a valid one
|
||||
{"{a}{b,c}", []string{"{a}b", "{a}c"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, err := expandBraces(tc.word)
|
||||
if err != nil {
|
||||
t.Errorf("expandBraces(%q) returned error: %v", tc.word, err)
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("expandBraces(%q) = %q, want %q", tc.word, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := expandBraces("{1..1000000}"); err == nil {
|
||||
t.Error("expected an error for a too large sequence")
|
||||
}
|
||||
if _, err := expandBraces("{1..11}{1..100}{1..100}"); err == nil {
|
||||
t.Error("expected an error for too many words")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandBraceWords(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"echo a{b,c} d", "echo ab ac d"},
|
||||
{"echo {1..3}|wc -w", "echo 1 2 3|wc -w"},
|
||||
{"echo a > {f}", "echo a > {f}"},
|
||||
{"echo {a,b} 2>&1", "echo a b 2>&1"},
|
||||
{"echo x # {a,b}", "echo x # {a,b}"},
|
||||
{`echo "{a,b} c" {d,e}`, `echo "{a,b} c" d e`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, err := expandBraceWords(tc.input)
|
||||
if err != nil || got != tc.want {
|
||||
t.Errorf("expandBraceWords(%q) = %q, %v; want %q", tc.input, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, input := range []string{"echo a > {f,g}", "cat < x{1..2}", "echo a 2>> {a,b}"} {
|
||||
if _, err := expandBraceWords(input); err == nil || !strings.Contains(err.Error(), "ambiguous redirect") {
|
||||
t.Errorf("expandBraceWords(%q): %v, want ambiguous redirect", input, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecInput_BraceExpansion(t *testing.T) {
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
dir := t.TempDir()
|
||||
os.Chdir(dir)
|
||||
|
||||
if err := execInput("mkdir -p d/{x,y}/{1..2} && touch d/x/1/f{a,b}.txt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"d/x/1", "d/x/2", "d/y/1", "d/y/2", "d/x/1/fa.txt", "d/x/1/fb.txt"} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("%s was not created", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Brace expansion comes before wildcards and variables
|
||||
t.Setenv("GOSH_BRACE", "v")
|
||||
execInput("echo d/x/1/*.{txt,go} {${GOSH_BRACE},w}1 > out")
|
||||
if got, _ := os.ReadFile("out"); string(got) != "d/x/1/fa.txt d/x/1/fb.txt d/x/1/*.go v1 w1\n" {
|
||||
t.Errorf("out = %q", got)
|
||||
}
|
||||
|
||||
if err := execInput("echo a > {f,g}"); err == nil || lastStatus != 1 {
|
||||
t.Errorf("ambiguous redirect: %v, $? = %d", err, lastStatus)
|
||||
}
|
||||
}
|
||||
@@ -162,9 +162,7 @@ func (j *job) update(pid int, ws syscall.WaitStatus) {
|
||||
switch {
|
||||
case ws.Stopped():
|
||||
p.stopped = true
|
||||
case ws.Continued():
|
||||
p.stopped = false
|
||||
default:
|
||||
case ws.Exited() || ws.Signaled():
|
||||
p.finished, p.stopped = true, false
|
||||
p.err = waitStatusError(ws)
|
||||
p.cmd.Process.Release()
|
||||
@@ -185,17 +183,30 @@ func waitStatusError(ws syscall.WaitStatus) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// wait waits until no process of the job is running any more (all finished
|
||||
// or, with job control, stopped). With nohang it only collects the status
|
||||
// changes that are already available and does not block.
|
||||
func (j *job) wait(nohang bool) {
|
||||
// waitFlags returns the options for wait4: WUNTRACED to see stopped
|
||||
// processes (with job control) and WNOHANG to not block.
|
||||
//
|
||||
// WCONTINUED is not used: Go's syscall.WaitStatus decodes the "continued"
|
||||
// status wrongly on FreeBSD (as killed by SIGCONT) and macOS (as stopped).
|
||||
// The shell marks jobs as running itself when it continues them (fg, bg); a
|
||||
// job continued from outside (kill -CONT) stays "Stopped" in the job table
|
||||
// until it stops again or exits.
|
||||
func waitFlags(j *job, nohang bool) int {
|
||||
flags := 0
|
||||
if jobControl && j.pgid != 0 {
|
||||
flags |= syscall.WUNTRACED | syscall.WCONTINUED
|
||||
flags |= syscall.WUNTRACED
|
||||
}
|
||||
if nohang {
|
||||
flags |= syscall.WNOHANG
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
// wait waits until no process of the job is running any more (all finished
|
||||
// or, with job control, stopped). With nohang it only collects the status
|
||||
// changes that are already available and does not block.
|
||||
func (j *job) wait(nohang bool) {
|
||||
flags := waitFlags(j, nohang)
|
||||
|
||||
for {
|
||||
// With job control all processes of the job are in its process
|
||||
|
||||
@@ -105,6 +105,21 @@ func (s *ptyShell) expect(want string) {
|
||||
s.t.Fatalf("timeout waiting for %q, output:\n%s", want, s.out.String())
|
||||
}
|
||||
|
||||
func TestWaitFlags_NoWCONTINUED(t *testing.T) {
|
||||
defer func(saved bool) { jobControl = saved }(jobControl)
|
||||
jobControl = true
|
||||
j := &job{pgid: 1}
|
||||
for _, nohang := range []bool{false, true} {
|
||||
flags := waitFlags(j, nohang)
|
||||
if flags&syscall.WCONTINUED != 0 {
|
||||
t.Errorf("waitFlags(nohang=%v) contains WCONTINUED, which Go decodes wrongly on BSD", nohang)
|
||||
}
|
||||
if flags&syscall.WUNTRACED == 0 {
|
||||
t.Errorf("waitFlags(nohang=%v) misses WUNTRACED", nohang)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobControl(t *testing.T) {
|
||||
s := startPtyShell(t)
|
||||
|
||||
@@ -120,6 +135,13 @@ func TestJobControl(t *testing.T) {
|
||||
s.send("bg\n")
|
||||
s.expect("[1]+ Running sleep 30 &")
|
||||
|
||||
// After bg the job keeps running and is not reported as finished
|
||||
// (regression: on FreeBSD the "continued" status was read as killed by
|
||||
// SIGCONT, "Exit 147")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
s.send("jobs\n")
|
||||
s.expect("[1]+ Running sleep 30 &")
|
||||
|
||||
// fg brings it back, Ctrl+C reaches only the job
|
||||
s.send("fg %1\n")
|
||||
s.expect("sleep 30")
|
||||
|
||||
@@ -131,6 +131,8 @@ type token struct {
|
||||
// - '...' is taken literally, nothing is expanded inside
|
||||
// - "..." expands $VAR and ${VAR}; \ only escapes $ " \ and `
|
||||
// - outside quotes \ escapes any character
|
||||
// - $(command) is replaced by the output of command (see commandSubst);
|
||||
// unquoted, the output is split into words at blanks and newlines
|
||||
// - $VAR, ${VAR} and ${VAR:-default} are expanded outside single quotes;
|
||||
// an unset variable is an error, so that e.g. "rm -rf $DIR/x" never runs
|
||||
// with an empty $DIR
|
||||
@@ -190,6 +192,32 @@ func tokenize(input string) ([]token, error) {
|
||||
case ch == '\\':
|
||||
startRaw(i)
|
||||
escaped = true
|
||||
case isSubstStart(input, i):
|
||||
startRaw(i)
|
||||
end := substEnd(input, i)
|
||||
if end < 0 {
|
||||
return nil, errors.New("missing ) in command substitution")
|
||||
}
|
||||
out, err := commandSubst(input[i+2 : end])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if quote == '"' {
|
||||
literal(out)
|
||||
inWord = true
|
||||
} else {
|
||||
// Unquoted, the output is split into words at blanks and
|
||||
// newlines (but, unlike in sh, wildcards in it are not
|
||||
// expanded)
|
||||
for k, field := range strings.Fields(out) {
|
||||
if k > 0 {
|
||||
endWord()
|
||||
}
|
||||
literal(field)
|
||||
inWord = true
|
||||
}
|
||||
}
|
||||
i = end
|
||||
case ch == '$':
|
||||
startRaw(i)
|
||||
value, n, err := expandVar(input[i:])
|
||||
@@ -612,21 +640,23 @@ func getPrompt() string {
|
||||
c := parseColors(os.Getenv("GOSH_COLORS"))
|
||||
colors = &c
|
||||
}
|
||||
return buildPrompt(username, hostname, wd, os.Geteuid() == 0, colors)
|
||||
return buildPrompt(username, hostname, wd, os.Geteuid() == 0, lastStatus, colors)
|
||||
}
|
||||
|
||||
// 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
|
||||
user string // user@host
|
||||
root string // user@host when running as root
|
||||
path string // working directory
|
||||
error string // exit status of the last command, if it failed
|
||||
}
|
||||
|
||||
// defaultColors: bold green user, bold red root, bold blue directory
|
||||
var defaultColors = promptColors{user: "1;32", root: "1;31", path: "1;34"}
|
||||
// defaultColors: bold green user, bold red root, bold blue directory, bold
|
||||
// red exit status
|
||||
var defaultColors = promptColors{user: "1;32", root: "1;31", path: "1;34", error: "1;31"}
|
||||
|
||||
// parseColors reads a GOSH_COLORS value like "user=1;33:root=1;31:path=36"
|
||||
// parseColors reads a GOSH_COLORS value like "user=1;33:path=36:error=31"
|
||||
// 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.
|
||||
@@ -644,6 +674,8 @@ func parseColors(spec string) promptColors {
|
||||
colors.root = value
|
||||
case "path":
|
||||
colors.path = value
|
||||
case "error":
|
||||
colors.error = value
|
||||
}
|
||||
}
|
||||
return colors
|
||||
@@ -669,20 +701,28 @@ func colorize(text, sgr string) string {
|
||||
return "\x1b[" + sgr + "m" + text + "\x1b[0m"
|
||||
}
|
||||
|
||||
// buildPrompt formats the prompt as user@host:path $. The parts are sanitized
|
||||
// buildPrompt formats the prompt as user@host:path $, or user@host:path [N] $
|
||||
// if the last command failed with exit status N. The parts are sanitized
|
||||
// before the color codes are added, so only our own escape sequences end up
|
||||
// in the prompt. colors == nil disables colors.
|
||||
func buildPrompt(username, hostname, path string, root bool, colors *promptColors) string {
|
||||
func buildPrompt(username, hostname, path string, root bool, status int, colors *promptColors) string {
|
||||
userHost := sanitize(username + "@" + hostname)
|
||||
path = sanitize(path)
|
||||
var failed string
|
||||
if status != 0 {
|
||||
failed = " [" + strconv.Itoa(status) + "]"
|
||||
}
|
||||
if colors == nil {
|
||||
return userHost + ":" + path + " $ "
|
||||
return userHost + ":" + path + failed + " $ "
|
||||
}
|
||||
userColor := colors.user
|
||||
if root {
|
||||
userColor = colors.root
|
||||
}
|
||||
return colorize(userHost, userColor) + ":" + colorize(path, colors.path) + " $ "
|
||||
if failed != "" {
|
||||
failed = " " + colorize(failed[1:], colors.error)
|
||||
}
|
||||
return colorize(userHost, userColor) + ":" + colorize(path, colors.path) + failed + " $ "
|
||||
}
|
||||
|
||||
// useColor reports whether the prompt should be colored: stdout must be a
|
||||
@@ -815,7 +855,7 @@ func currentWord(s string) (word string, start int, quote byte) {
|
||||
func escapeWord(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if strings.ContainsRune(" \t\\'\";|&<>*?[$", r) {
|
||||
if strings.ContainsRune(" \t\\'\";|&<>*?[${", r) {
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
@@ -1275,6 +1315,11 @@ scan:
|
||||
}
|
||||
case ch == '\\':
|
||||
escaped = true
|
||||
case isSubstStart(input, i):
|
||||
// Skip $(...) as a whole, its ; | & belong to the subshell
|
||||
if j := substEnd(input, i); j >= 0 {
|
||||
i = j
|
||||
}
|
||||
case ch == '$' && i+1 < len(input) && input[i+1] == '{':
|
||||
// Skip ${...} as a whole, like expandVar does
|
||||
if j := strings.IndexByte(input[i:], '}'); j >= 0 {
|
||||
@@ -1354,11 +1399,21 @@ func execInput(input string) error {
|
||||
// execCommand executes a pipeline of simple commands with redirections, in
|
||||
// the background if background is set.
|
||||
func execCommand(input string, background bool) error {
|
||||
// Brace expansion comes first, so that its results are expanded further
|
||||
substStatus = 0
|
||||
input, err := expandBraceWords(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Split the input into words and operators, expanding variables and ~ on
|
||||
// the way.
|
||||
tokens, err := tokenize(input)
|
||||
var synErr syntaxError
|
||||
if errors.As(err, &synErr) {
|
||||
var (
|
||||
synErr syntaxError
|
||||
statusErr statusError
|
||||
)
|
||||
if errors.As(err, &synErr) || errors.As(err, &statusErr) {
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
@@ -1369,6 +1424,10 @@ func execCommand(input string, background bool) error {
|
||||
return err
|
||||
}
|
||||
if len(stages) == 0 {
|
||||
// A command that consists only of $(...) has its status
|
||||
if substStatus != 0 {
|
||||
return statusError(substStatus)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// "exit" with stopped jobs only warns, unless it is repeated right away
|
||||
@@ -1783,15 +1842,22 @@ Features:
|
||||
override with $GOSH_HISTFILE); lines starting with a space are not saved
|
||||
- Tab completion for commands and file paths
|
||||
- Tilde expansion (~, ~/path) at the start of unquoted words
|
||||
- Brace expansion: a{b,c}d -> abd acd, {1..5}, {01..10..2}, {a..e}
|
||||
- Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files
|
||||
unless the pattern starts with a dot); quoted wildcards and values of
|
||||
variables are not expanded, a pattern without match is kept as is
|
||||
- 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
|
||||
- Prompt user@host:dir $, with [N] before the $ if the last command
|
||||
failed with exit status N
|
||||
- Colored prompt (user@host green, red for root; directory blue; exit
|
||||
status red), configurable with $GOSH_COLORS, e.g.
|
||||
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
|
||||
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
|
||||
using an unset variable is an error, use ${VAR:-} for an empty default
|
||||
- Command substitution: $(command) is replaced by the output of command
|
||||
(run in a subshell); unquoted it is split into words, "$(command)"
|
||||
stays one word
|
||||
- Command lists: a ; b (run both), a && b (b only if a succeeded),
|
||||
a || b (b only if a failed); $? is the exit status of the last command
|
||||
- Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
|
||||
|
||||
+35
-9
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -892,6 +893,11 @@ func TestMain(m *testing.M) {
|
||||
// Never read the system's startup files; tests that read the user's
|
||||
// files set $HOME themselves
|
||||
sysConfDir = filepath.Join(dir, "etc")
|
||||
// Command substitution runs gosh, not the test binary
|
||||
subshellPath = filepath.Join(dir, "gosh")
|
||||
if out, err := exec.Command("go", "build", "-o", subshellPath, ".").CombinedOutput(); err != nil {
|
||||
panic(fmt.Sprintf("building gosh: %v\n%s", err, out))
|
||||
}
|
||||
code := m.Run()
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
@@ -1054,24 +1060,24 @@ func TestGetPrompt_EscapesControlCharacters(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildPrompt_Color(t *testing.T) {
|
||||
got := buildPrompt("flow", "host", "~/src", false, &defaultColors)
|
||||
got := buildPrompt("flow", "host", "~/src", false, 0, &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, &defaultColors)
|
||||
got = buildPrompt("root", "host", "/", true, 0, &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, nil)
|
||||
got = buildPrompt("flow", "host", "~/src", false, 0, 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)
|
||||
got = buildPrompt("flow", "host", "~/src", false, 0, &colors)
|
||||
if got != "\x1b[38;5;208mflow@host\x1b[0m:~/src $ " {
|
||||
t.Errorf("buildPrompt() with custom colors = %q", got)
|
||||
}
|
||||
@@ -1083,13 +1089,14 @@ func TestParseColors(t *testing.T) {
|
||||
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: ""}},
|
||||
{"user=1;33", promptColors{user: "1;33", root: "1;31", path: "1;34", error: "1;31"}},
|
||||
{"user=33:root=41;97:path=38;5;208", promptColors{user: "33", root: "41;97", path: "38;5;208", error: "1;31"}},
|
||||
{"error=33", promptColors{user: "1;32", root: "1;31", path: "1;34", error: "33"}},
|
||||
{"path=", promptColors{user: "1;32", root: "1;31", path: "", error: "1;31"}},
|
||||
{"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"}},
|
||||
{"user=35::path=36", promptColors{user: "35", root: "1;31", path: "36", error: "1;31"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := parseColors(tc.spec); got != tc.want {
|
||||
@@ -1098,8 +1105,27 @@ func TestParseColors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt_Status(t *testing.T) {
|
||||
if got := buildPrompt("flow", "host", "~", false, 0, nil); got != "flow@host:~ $ " {
|
||||
t.Errorf("status 0: %q", got)
|
||||
}
|
||||
if got := buildPrompt("flow", "host", "~", false, 127, nil); got != "flow@host:~ [127] $ " {
|
||||
t.Errorf("status 127 without color: %q", got)
|
||||
}
|
||||
got := buildPrompt("flow", "host", "~", false, 1, &defaultColors)
|
||||
want := "\x1b[1;32mflow@host\x1b[0m:\x1b[1;34m~\x1b[0m \x1b[1;31m[1]\x1b[0m $ "
|
||||
if got != want {
|
||||
t.Errorf("status 1 with color = %q, want %q", got, want)
|
||||
}
|
||||
colors := defaultColors
|
||||
colors.error = ""
|
||||
if got := buildPrompt("flow", "host", "~", false, 2, &colors); !strings.HasSuffix(got, " [2] $ ") {
|
||||
t.Errorf("status without error color = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt_ColorEscapesControlCharacters(t *testing.T) {
|
||||
got := buildPrompt("flow", "host", "evil\x1b[2J\x07", false, &defaultColors)
|
||||
got := buildPrompt("flow", "host", "evil\x1b[2J\x07", false, 0, &defaultColors)
|
||||
if strings.Contains(got, "\x1b[2J") || strings.Contains(got, "\x07") {
|
||||
t.Errorf("prompt contains control characters from path: %q", got)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
// Command substitution: $(command) is replaced by the output of command,
|
||||
// which runs in a subshell (a new gosh process), so that cd, set etc. in it
|
||||
// do not change the shell.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// subshellPath is the gosh binary used for command substitution; empty means
|
||||
// the running executable. Tests set it, since there the running executable is
|
||||
// the test binary.
|
||||
var subshellPath string
|
||||
|
||||
// substStatus is the exit status of the last command substitution of the
|
||||
// current command (a command that consists only of a substitution has this
|
||||
// status, like in sh).
|
||||
var substStatus int
|
||||
|
||||
// isSubstStart reports whether a command substitution $( starts at s[i].
|
||||
func isSubstStart(s string, i int) bool {
|
||||
return s[i] == '$' && i+1 < len(s) && s[i+1] == '('
|
||||
}
|
||||
|
||||
// substEnd returns the index of the ) that ends the command substitution
|
||||
// starting with $( at s[i], or -1 if it is not terminated. Quotes, escapes
|
||||
// and nested substitutions inside are taken into account.
|
||||
func substEnd(s string, i int) int {
|
||||
depth := 0
|
||||
var quote byte
|
||||
for j := i + 1; j < len(s); j++ {
|
||||
ch := s[j]
|
||||
switch {
|
||||
case quote == '\'':
|
||||
if ch == '\'' {
|
||||
quote = 0
|
||||
}
|
||||
case ch == '\\':
|
||||
j++
|
||||
case quote == '"':
|
||||
if ch == '"' {
|
||||
quote = 0
|
||||
} else if isSubstStart(s, j) {
|
||||
end := substEnd(s, j)
|
||||
if end < 0 {
|
||||
return -1
|
||||
}
|
||||
j = end
|
||||
}
|
||||
case ch == '"' || ch == '\'':
|
||||
quote = ch
|
||||
case ch == '(':
|
||||
depth++
|
||||
case ch == ')':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return j
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// commandSubst runs command in a subshell and returns its output without
|
||||
// trailing newlines. stdin and stderr are those of the shell.
|
||||
func commandSubst(command string) (string, error) {
|
||||
if strings.HasPrefix(command, "(") {
|
||||
return "", errors.New("arithmetic expansion $((...)) is not supported")
|
||||
}
|
||||
path := subshellPath
|
||||
if path == "" {
|
||||
var err error
|
||||
if path, err = os.Executable(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := exec.Command(path, "-c", command)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, &out, os.Stderr
|
||||
err := cmd.Run()
|
||||
substStatus = exitStatus(err)
|
||||
if substStatus == 128+int(syscall.SIGINT) {
|
||||
// Ctrl+C aborts the whole command, like in bash
|
||||
return "", statusError(substStatus)
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if err != nil && !errors.As(err, &exitErr) {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n"), nil
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSubstEnd(t *testing.T) {
|
||||
tests := []struct {
|
||||
s string
|
||||
want int
|
||||
}{
|
||||
{"$(echo a)", 8},
|
||||
{"$(echo a) b", 8},
|
||||
{"$(echo $(date))x", 14},
|
||||
{`$(echo ")")`, 10},
|
||||
{`$(echo ')')`, 10},
|
||||
{`$(echo \))`, 9},
|
||||
{`$(echo "$(echo ")")")`, 20},
|
||||
{"$(echo (a))", 10},
|
||||
{"$(echo a", -1},
|
||||
{`$(echo ")`, -1},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := substEnd(tc.s, 0); got != tc.want {
|
||||
t.Errorf("substEnd(%q) = %d, want %d", tc.s, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitList_SkipsSubstitution(t *testing.T) {
|
||||
got, err := splitList(`echo $(a; b && c | d) "$(e || f)" & g`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []listItem{{"", `echo $(a; b && c | d) "$(e || f)" `, true}, {";", " g", false}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("splitList = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandAliasesAndBraces_SkipSubstitution(t *testing.T) {
|
||||
withAliases(t, map[string]string{"ll": "ls -l"})
|
||||
if got := expandAliases("echo $(ll; ll) && ll"); got != "echo $(ll; ll) && ls -l" {
|
||||
t.Errorf("expandAliases = %q", got)
|
||||
}
|
||||
got, err := expandBraceWords("echo $(echo {a,b}) {c,d}")
|
||||
if err != nil || got != "echo $(echo {a,b}) c d" {
|
||||
t.Errorf("expandBraceWords = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecInput_CommandSubstitution(t *testing.T) {
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
dir := t.TempDir()
|
||||
os.Chdir(dir)
|
||||
os.WriteFile("a.go", nil, 0644)
|
||||
os.WriteFile("b.go", nil, 0644)
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"echo $(echo hello) > out", "hello\n"},
|
||||
// Unquoted, the output is split into words
|
||||
{"printf '[%s]' $(printf 'a b\\nc\\n\\n') > out", "[a][b][c]"},
|
||||
{"printf '[%s]' x$(echo a b)y > out", "[xa][by]"},
|
||||
// Quoted, it is a single word, only trailing newlines are removed
|
||||
{`printf '[%s]' "$(printf 'a b\nc\n\n')" > out`, "[a b\nc]"},
|
||||
{`printf '[%s]' "$(true)" > out`, "[]"},
|
||||
{"printf '[%s]' $(true) x > out", "[x]"},
|
||||
// Nested, with quotes and operators inside
|
||||
{"echo $(echo $(echo nested)) > out", "nested\n"},
|
||||
{`echo "$(echo "a;b|c")" > out`, "a;b|c\n"},
|
||||
{"echo $(echo a | tr a-z A-Z) > out", "A\n"},
|
||||
// Wildcards in the output are not expanded, in the command they are
|
||||
{`echo $(echo "*") > out`, "*\n"},
|
||||
{"echo $(echo *.go) > out", "a.go b.go\n"},
|
||||
// Quoted or escaped, $( is literal
|
||||
{`echo '$(echo a)' \$(echo b) > out`, "$(echo a) $(echo b)\n"},
|
||||
// The subshell does not change the shell
|
||||
{"echo $(cd /; pwd) > out; pwd >> out", "/\n" + dir + "\n"},
|
||||
{"set GOSH_SUBST=$(echo value); echo $GOSH_SUBST > out", "value\n"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if err := execInput(tc.input); err != nil {
|
||||
t.Errorf("%q: %v", tc.input, err)
|
||||
}
|
||||
if got, _ := os.ReadFile("out"); string(got) != tc.want {
|
||||
t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
os.Unsetenv("GOSH_SUBST")
|
||||
|
||||
// A command that consists only of a substitution has its status
|
||||
execInput("$(sh -c 'exit 3')")
|
||||
if lastStatus != 3 {
|
||||
t.Errorf("$? = %d after $(exit 3), want 3", lastStatus)
|
||||
}
|
||||
// Otherwise the status of the command counts
|
||||
execInput("echo $(false) > out")
|
||||
if lastStatus != 0 {
|
||||
t.Errorf("$? = %d after echo $(false), want 0", lastStatus)
|
||||
}
|
||||
|
||||
for input, wantErr := range map[string]string{
|
||||
"echo $(echo a": "invalid input: missing ) in command substitution",
|
||||
"echo $((1 + 2))": "invalid input: arithmetic expansion $((...)) is not supported",
|
||||
} {
|
||||
if err := execInput(input); err == nil || err.Error() != wantErr {
|
||||
t.Errorf("%q: error %v, want %q", input, err, wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user