add pipes and redirections
- tokenizer recognizes unquoted |, <, >, >>, N>, N>>, N>&M, N<&M, &> and &>>; only a word of unquoted digits is a file descriptor - pipelines run concurrently, the status is the one of the last command, errors of earlier stages are printed - redirections are applied left to right (fds 0-2), a redirection without a command creates/truncates the file - pwd and help can be redirected and piped; cd, set, export, exit and quit are rejected in pipelines - syntax errors have exit status 2 - completion: commands after |, ;, && and ||, files after redirections; operator characters are escaped in completed names Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f5c14dff90
commit
c46903e3d2
3 files changed
+570
-43
No files matched your search
+149
@@ -859,6 +859,15 @@ func TestShellCompleter_Do(t *testing.T) {
|
||||
{"ls " + tmpDir + "/ma", []string{"in.go"}, 2},
|
||||
{"ls xyz", nil, 3},
|
||||
{"ls ~", []string{"/"}, 1},
|
||||
// Commands are completed after |, ;, && and ||, files after redirections
|
||||
{"ls | gi", []string{"tty "}, 2},
|
||||
{"ls|gi", []string{"tty "}, 2},
|
||||
{"true; gi", []string{"tty "}, 2},
|
||||
{"true && gi", []string{"tty "}, 2},
|
||||
{"false || gi", []string{"tty "}, 2},
|
||||
{"ls > ma", []string{"in.go"}, 2},
|
||||
{"ls 2>ma", []string{"in.go"}, 2},
|
||||
{"cat <ma", []string{"in.go"}, 2},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, length := completeLine(tt.line)
|
||||
@@ -1471,3 +1480,143 @@ func TestExecInput_CommandNotFound(t *testing.T) {
|
||||
t.Errorf("$? = %d, want 127", lastStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenize_Operators(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want []token
|
||||
}{
|
||||
{"a | b", []token{{word: "a"}, {op: "|"}, {word: "b"}}},
|
||||
{"a|b", []token{{word: "a"}, {op: "|"}, {word: "b"}}},
|
||||
{"a > f", []token{{word: "a"}, {op: ">", fd: 1}, {word: "f"}}},
|
||||
{"a >>f", []token{{word: "a"}, {op: ">>", fd: 1}, {word: "f"}}},
|
||||
{"a < f", []token{{word: "a"}, {op: "<", fd: 0}, {word: "f"}}},
|
||||
{"a 2>f", []token{{word: "a"}, {op: ">", fd: 2}, {word: "f"}}},
|
||||
{"a 2>>f", []token{{word: "a"}, {op: ">>", fd: 2}, {word: "f"}}},
|
||||
{"a 2>&1", []token{{word: "a"}, {op: ">&", fd: 2, dup: 1}}},
|
||||
{"a >&2", []token{{word: "a"}, {op: ">&", fd: 1, dup: 2}}},
|
||||
{"a 0<&2", []token{{word: "a"}, {op: "<&", fd: 0, dup: 2}}},
|
||||
{"a &>f", []token{{word: "a"}, {op: "&>", fd: 1}, {word: "f"}}},
|
||||
{"a &>>f", []token{{word: "a"}, {op: "&>>", fd: 1}, {word: "f"}}},
|
||||
// Only a word of unquoted digits is a file descriptor
|
||||
{"a x2>f", []token{{word: "a"}, {word: "x2"}, {op: ">", fd: 1}, {word: "f"}}},
|
||||
{`a "2">f`, []token{{word: "a"}, {word: "2"}, {op: ">", fd: 1}, {word: "f"}}},
|
||||
{"a 2 >f", []token{{word: "a"}, {word: "2"}, {op: ">", fd: 1}, {word: "f"}}},
|
||||
// Quoted and escaped operators are words
|
||||
{`echo "a|b" 'c>d' e\<f`, []token{{word: "echo"}, {word: "a|b"}, {word: "c>d"}, {word: "e<f"}}},
|
||||
// A lone & is a word
|
||||
{"a & b", []token{{word: "a"}, {word: "&"}, {word: "b"}}},
|
||||
{"cat ~|x", []token{{word: "cat"}, {word: getHomeDir()}, {op: "|"}, {word: "x"}}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, err := tokenize(tc.input)
|
||||
if err != nil {
|
||||
t.Errorf("tokenize(%q) returned error: %v", tc.input, err)
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("tokenize(%q) = %+v, want %+v", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecInput_SyntaxErrors(t *testing.T) {
|
||||
for _, input := range []string{"echo a |", "| echo a", "echo a | | b", "echo a >", "echo a > | b", "echo a >&", "echo a 2>&x", "; a"} {
|
||||
err := execInput(input)
|
||||
if err == nil || !strings.Contains(err.Error(), "syntax error") {
|
||||
t.Errorf("execInput(%q) = %v, want syntax error", input, err)
|
||||
}
|
||||
if lastStatus != 2 {
|
||||
t.Errorf("execInput(%q): $? = %d, want 2", input, lastStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecInput_PipesAndRedirections(t *testing.T) {
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
dir := t.TempDir()
|
||||
os.Chdir(dir)
|
||||
|
||||
read := func(name string) string {
|
||||
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Errorf("reading %s: %v", name, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
file string
|
||||
want string
|
||||
status int
|
||||
}{
|
||||
{`printf 'b\na\nc\n' | sort | head -n 2 > out`, "out", "a\nb\n", 0},
|
||||
{"echo one > f1; echo two >> f1", "f1", "one\ntwo\n", 0},
|
||||
{"echo one > f2; echo two > f2", "f2", "two\n", 0},
|
||||
{"tr a-z A-Z < f1 > f3", "f3", "ONE\nTWO\n", 0},
|
||||
{"sh -c 'echo out; echo err >&2' > f4 2>&1", "f4", "out\nerr\n", 0},
|
||||
{"sh -c 'echo out; echo err >&2' &> f5", "f5", "out\nerr\n", 0},
|
||||
{"sh -c 'echo err >&2' 2> f6", "f6", "err\n", 0},
|
||||
// 2>&1 before >file sends stderr to the old stdout (the pipe)
|
||||
{"sh -c 'echo out; echo err >&2' 2>&1 > /dev/null | cat > f7", "f7", "err\n", 0},
|
||||
{"sh -c 'echo err >&2' 2>&1 | tr a-z A-Z > f8", "f8", "ERR\n", 0},
|
||||
// Builtins write to redirections and pipes
|
||||
{"pwd > f9", "f9", dir + "\n", 0},
|
||||
{"pwd | cat > f10", "f10", dir + "\n", 0},
|
||||
{"help | head -n 1 > f11", "f11", "gosh - Go Shell\n", 0},
|
||||
// Errors of builtins go to the redirected stderr
|
||||
{"cd /gosh/no/such/dir 2> f12", "f12", "gosh: chdir /gosh/no/such/dir: no such file or directory\n", 1},
|
||||
// The status of a pipeline is the one of the last command
|
||||
{"false | true > f13", "f13", "", 0},
|
||||
{"true | false > f14", "f14", "", 1},
|
||||
// A redirection without a command creates the file
|
||||
{"> f15", "f15", "", 0},
|
||||
// SIGPIPE ends the writer when the reader exits
|
||||
{"yes | head -n 1 > f16", "f16", "y\n", 0},
|
||||
// A command that is not found does not stop the pipeline
|
||||
{"gosh-no-such-command | echo after > f17", "f17", "after\n", 0},
|
||||
{"echo before | gosh-no-such-command 2> f18", "f18", "", 127},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
execInput(tc.input)
|
||||
if lastStatus != tc.status {
|
||||
t.Errorf("%q: $? = %d, want %d", tc.input, lastStatus, tc.status)
|
||||
}
|
||||
if got := read(tc.file); got != tc.want {
|
||||
t.Errorf("%q: %s = %q, want %q", tc.input, tc.file, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecInput_RedirectionErrors(t *testing.T) {
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(t.TempDir())
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{"cat < gosh-no-such-file", "gosh-no-such-file: no such file or directory"},
|
||||
{"echo a > gosh-no-such-dir/f", "gosh-no-such-dir/f: no such file or directory"},
|
||||
{"echo a 3> f", "3: bad file descriptor"},
|
||||
{"echo a 2>&5", "5: bad file descriptor"},
|
||||
{"echo a | cd /", "cd: cannot be used in a pipeline"},
|
||||
{"echo a | exit 3", "exit: cannot be used in a pipeline"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
err := execInput(tc.input)
|
||||
if err == nil || err.Error() != tc.wantErr {
|
||||
t.Errorf("execInput(%q) = %v, want %q", tc.input, err, tc.wantErr)
|
||||
}
|
||||
if lastStatus != 1 {
|
||||
t.Errorf("execInput(%q): $? = %d, want 1", tc.input, lastStatus)
|
||||
}
|
||||
}
|
||||
// The command is not run if a redirection fails
|
||||
if _, err := os.Stat("f"); err == nil {
|
||||
t.Error("file f must not be created for a bad file descriptor")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user