summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorRose Hogenson <rosehogenson@posteo.net>2026-08-24 22:49:01 -0700
committerRose Hogenson <rosehogenson@posteo.net>2026-08-24 22:49:01 -0700
commitec47b533e2d748f6831f0fd2f68e93d0954c1d8f (patch)
tree3f456337a0fafbf51fda17ac52e2102ddcf47468
downloadrsh-ec47b533e2d748f6831f0fd2f68e93d0954c1d8f.tar.zst
Initial commitHEADmain
-rw-r--r--go.mod5
-rw-r--r--go.sum2
-rw-r--r--rsh.go182
-rw-r--r--rsh_test.go93
-rw-r--r--testdata/fuzz/FuzzSplitTokens/5e6d51ab3f0259672
5 files changed, 284 insertions, 0 deletions
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..1c5d030
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module roseh.moe/cmd/rsh
+
+go 1.26.5
+
+require github.com/google/go-cmp v0.7.0
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..40e761a
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,2 @@
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
diff --git a/rsh.go b/rsh.go
new file mode 100644
index 0000000..80c52e0
--- /dev/null
+++ b/rsh.go
@@ -0,0 +1,182 @@
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+)
+
+type lexer struct {
+ insertConcat bool
+}
+
+func (l *lexer) splitTokens(data []byte, atEOF bool) (advance int, token []byte, err error) {
+ n := 0
+Whitespace:
+ for ; n < len(data); n++ {
+ switch data[n] {
+ case ' ', '\t':
+ l.insertConcat = false
+ case '#':
+ i := n + 1
+ for ; i < len(data) && data[i] != '\n'; i++ {
+ }
+ if i < len(data) {
+ return i + 1, []byte(";"), nil
+ }
+ if atEOF {
+ return i, nil, nil
+ }
+ return n, nil, nil
+ case '\\':
+ if n+1 < len(data) && data[n+1] == '\n' {
+ n++
+ l.insertConcat = false
+ continue
+ }
+ fallthrough
+ default:
+ break Whitespace
+ }
+ }
+ data = data[n:]
+ if len(data) == 0 {
+ return n, nil, nil
+ }
+ switch data[0] {
+ case '$':
+ if l.insertConcat {
+ l.insertConcat = false
+ return n, []byte{}, nil
+ }
+ i := 1
+ for ; i < len(data); i++ {
+ if b := data[i]; !(b == '_' || '0' <= b && b <= '9' || 'a' <= b && b <= 'z' || 'A' <= b && b <= 'Z') {
+ break
+ }
+ }
+ if i == len(data) && !atEOF {
+ return n, nil, nil
+ }
+ if i < len(data) && data[i] == '(' {
+ i++
+ } else {
+ l.insertConcat = true
+ }
+ if i == 1 {
+ return 0, nil, fmt.Errorf(`invalid token "$"`)
+ }
+ return n + i, data[:i], nil
+ case '<', '>':
+ if len(data) == 1 && !atEOF {
+ return n, nil, nil
+ }
+ l.insertConcat = false
+ if len(data) > 1 && data[1] == '(' {
+ return n + 2, data[:2], nil
+ }
+ return n + 1, data[:1], nil
+ case '\'':
+ if l.insertConcat {
+ l.insertConcat = false
+ return n, []byte{}, nil
+ }
+ i := 1
+ for ; i < len(data); i++ {
+ if data[i] == '\'' {
+ if i+1 == len(data) && !atEOF {
+ return n, nil, nil
+ }
+ if i+1 == len(data) || data[i+1] != '\'' {
+ break
+ }
+ }
+ }
+ if i == len(data) {
+ if atEOF {
+ return 0, nil, fmt.Errorf("unterminated string")
+ }
+ return n, nil, nil
+ }
+ i++
+ l.insertConcat = true
+ return n + i, data[:i], nil
+ case '\n':
+ l.insertConcat = false
+ return n + 1, []byte(";"), nil
+ case '&':
+ if len(data) == 1 && !atEOF {
+ return n, nil, nil
+ }
+ l.insertConcat = false
+ if len(data) > 1 && data[1] == '&' {
+ return n + 2, data[:2], nil
+ }
+ return n + 1, data[:1], nil
+ case '|':
+ if len(data) == 1 && !atEOF {
+ return n, nil, nil
+ }
+ l.insertConcat = false
+ if len(data) > 1 && data[1] == '|' {
+ return n + 2, data[:2], nil
+ }
+ return n + 1, data[:1], nil
+ case ';':
+ l.insertConcat = false
+ return n + 1, data[:1], nil
+ case ')':
+ l.insertConcat = true
+ return n + 1, data[:1], nil
+ case '*':
+ if l.insertConcat {
+ l.insertConcat = false
+ return n, []byte{}, nil
+ }
+ l.insertConcat = true
+ return n + 1, data[:1], nil
+ default:
+ if l.insertConcat {
+ l.insertConcat = false
+ return n, []byte{}, nil
+ }
+ i := 1
+ Word:
+ for ; i < len(data); i++ {
+ switch data[i] {
+ case '\\':
+ if i+1 == len(data) && !atEOF {
+ return n, nil, nil
+ }
+ if i+1 < len(data) && data[i+1] == '\n' {
+ break Word
+ }
+ case '&', '|', '*', ')', ';', '$', '<', '>', '\'', '#', ' ', '\t', '\n':
+ break Word
+ }
+ }
+ if i == len(data) && !atEOF {
+ return n, nil, nil
+ }
+ l.insertConcat = true
+ return n + i, data[:i], nil
+ }
+}
+
+func splitTokens() bufio.SplitFunc {
+ return new(lexer).splitTokens
+}
+
+func main() {
+ scanner := bufio.NewScanner(os.Stdin)
+ scanner.Split(splitTokens())
+ var tokens []string
+ for scanner.Scan() {
+ tokens = append(tokens, scanner.Text())
+ }
+ if err := scanner.Err(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ fmt.Printf("%q\n", tokens)
+}
diff --git a/rsh_test.go b/rsh_test.go
new file mode 100644
index 0000000..988a2d8
--- /dev/null
+++ b/rsh_test.go
@@ -0,0 +1,93 @@
+package main
+
+import (
+ "bufio"
+ "strings"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+func TestSplitTokens(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ desc string
+ input string
+ want []string
+ }{{
+ desc: "SingleWord",
+ input: "a",
+ want: []string{"a"},
+ }, {
+ desc: "SplitWords",
+ input: "a b c",
+ want: []string{"a", "b", "c"},
+ }, {
+ desc: "Star",
+ input: "a*b",
+ want: []string{"a", "", "*", "", "b"},
+ }, {
+ desc: "Background",
+ input: "a&b",
+ want: []string{"a", "&", "b"},
+ }, {
+ desc: "Pipe",
+ input: "a|b",
+ want: []string{"a", "|", "b"},
+ }, {
+ desc: "And",
+ input: "a&&b",
+ want: []string{"a", "&&", "b"},
+ }, {
+ desc: "Newline",
+ input: "a\n",
+ want: []string{"a", ";"},
+ }, {
+ desc: "CaptureOutputAsFile",
+ input: "a<(b)",
+ want: []string{"a", "<(", "b", ")"},
+ }, {
+ desc: "Substitution",
+ input: "a$(b)c",
+ want: []string{"a", "", "$(", "b", ")", "", "c"},
+ }, {
+ desc: "Comment",
+ input: "abc#de;fg",
+ want: []string{"abc"},
+ }, {
+ desc: "CommentNewline",
+ input: "a#b\n",
+ want: []string{"a", ";"},
+ }, {
+ desc: "BackslashEOF",
+ input: `a\`,
+ want: []string{`a\`},
+ }} {
+ t.Run(tc.desc, func(t *testing.T) {
+ scanner := bufio.NewScanner(strings.NewReader(tc.input))
+ scanner.Split(splitTokens())
+ var got []string
+ for scanner.Scan() {
+ got = append(got, scanner.Text())
+ }
+ if err := scanner.Err(); err != nil {
+ t.Fatalf("splitTokens failed: %s", err)
+ }
+ if diff := cmp.Diff(tc.want, got); diff != "" {
+ t.Errorf("splitTokens produced unexpected diff (-want +got):\n%s", diff)
+ }
+ })
+ }
+}
+
+func FuzzSplitTokens(f *testing.F) {
+ testcases := []string{"a", "a b c", "a*b", "a&b", "a|b", "a&&b", "a\n", "a<(b)", "a$(b)c", "abc#de;fg", "a#b\n", `a\`}
+ for _, tc := range testcases {
+ f.Add([]byte(tc))
+ }
+ f.Fuzz(func(t *testing.T, input []byte) {
+ splitTokens()(input, true)
+ splitTokens()(input, false)
+ })
+}
diff --git a/testdata/fuzz/FuzzSplitTokens/5e6d51ab3f025967 b/testdata/fuzz/FuzzSplitTokens/5e6d51ab3f025967
new file mode 100644
index 0000000..0f66b13
--- /dev/null
+++ b/testdata/fuzz/FuzzSplitTokens/5e6d51ab3f025967
@@ -0,0 +1,2 @@
+go test fuzz v1
+[]byte("'0000000")