From 6f353d6d16a7903b559af49b6995f3aabc97b8d3 Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Mon, 9 Dec 2019 07:56:52 +0000 Subject: [PATCH] feat: Initial working version of tmux package --- .github/workflows/ci.yml | 16 +++ LICENSE | 18 ++++ Makefile | 5 + README.md | 5 + exec_runner.go | 11 ++ exec_runner_test.go | 30 ++++++ go.mod | 5 + go.sum | 11 ++ options_scope.go | 33 ++++++ options_scope_test.go | 27 +++++ runner.go | 5 + tmux.go | 81 ++++++++++++++ tmux_test.go | 224 +++++++++++++++++++++++++++++++++++++++ 13 files changed, 471 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 exec_runner.go create mode 100644 exec_runner_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 options_scope.go create mode 100644 options_scope_test.go create mode 100644 runner.go create mode 100644 tmux.go create mode 100644 tmux_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4c100e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,16 @@ +name: CI + +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Set up Go 1.13 + uses: actions/setup-go@v1 + with: + go-version: 1.13 + - name: Check out the code + uses: actions/checkout@v1 + - name: Run all tests + run: make test diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f6aed4f --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) 2019 Jim Myhrberg + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..417803b --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +default: test + +.PHONY: test +test: + go test -v ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..4da6207 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# go-tmux + +A small Go package for executing [Tmux](https://github.com/tmux/tmux) +commands. It was created as a helper package for running the test suite in +[tmux-themepack](https://github.com/jimeh/tmux-themepack). diff --git a/exec_runner.go b/exec_runner.go new file mode 100644 index 0000000..5a688d3 --- /dev/null +++ b/exec_runner.go @@ -0,0 +1,11 @@ +package tmux + +import ( + "os/exec" +) + +type ExecRunner struct{} + +func (r *ExecRunner) Run(command string, args ...string) ([]byte, error) { + return exec.Command(command, args...).CombinedOutput() +} diff --git a/exec_runner_test.go b/exec_runner_test.go new file mode 100644 index 0000000..9d32c02 --- /dev/null +++ b/exec_runner_test.go @@ -0,0 +1,30 @@ +package tmux + +import ( + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExecRunnerRun(t *testing.T) { + tests := []struct { + command string + args []string + }{ + {command: "pwd"}, + {command: "hostname"}, + {command: "uname"}, + } + + for _, tt := range tests { + runner := &ExecRunner{} + + expected, err := exec.Command(tt.command, tt.args...).CombinedOutput() + assert.NoError(t, err) + actual, err := runner.Run(tt.command, tt.args...) + assert.NoError(t, err) + + assert.Equal(t, expected, actual) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6023d21 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/jimeh/go-tmux + +go 1.13 + +require github.com/stretchr/testify v1.4.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f25b296 --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/options_scope.go b/options_scope.go new file mode 100644 index 0000000..da659cf --- /dev/null +++ b/options_scope.go @@ -0,0 +1,33 @@ +package tmux + +// OptionsScope represents one of the five scopes that Tmux holds options +// within. +type OptionsScope int + +const ( + Server OptionsScope = iota + 1 + GlobalSession + Session + GlobalWindow + Window +) + +// OptionsScopeFlags converts a given OptionsScope to the command line flags +// needed to restrict "set-option" and "show-options" commands to the scope in +// question. +func OptionsScopeFlags(scope OptionsScope) string { + switch scope { + case 0, Session: + return "" + case Server: + return "-s" + case GlobalSession: + return "-g" + case GlobalWindow: + return "-gw" + case Window: + return "-w" + default: + return "" + } +} diff --git a/options_scope_test.go b/options_scope_test.go new file mode 100644 index 0000000..4a69c4e --- /dev/null +++ b/options_scope_test.go @@ -0,0 +1,27 @@ +package tmux + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOptionsScopeFlags(t *testing.T) { + tests := []struct { + scope OptionsScope + flags string + }{ + {0, ""}, + {Server, "-s"}, + {GlobalSession, "-g"}, + {Session, ""}, + {GlobalWindow, "-gw"}, + {Window, "-w"}, + {38404, ""}, + {934, ""}, + } + + for _, tt := range tests { + assert.Equal(t, tt.flags, OptionsScopeFlags(tt.scope)) + } +} diff --git a/runner.go b/runner.go new file mode 100644 index 0000000..93161c0 --- /dev/null +++ b/runner.go @@ -0,0 +1,5 @@ +package tmux + +type Runner interface { + Run(string, ...string) ([]byte, error) +} diff --git a/tmux.go b/tmux.go new file mode 100644 index 0000000..88fa28a --- /dev/null +++ b/tmux.go @@ -0,0 +1,81 @@ +package tmux + +import ( + "bufio" + "bytes" + "regexp" +) + +var optMatcher = regexp.MustCompile(`^\s*([@\w-][\w-]+)\s+(.*)$`) +var quote = []byte(`"`) + +// Tmux enables easily running tmux commands. +type Tmux struct { + BinPath string + SocketName string + SocketPath string + Runner Runner +} + +// New returns a Tmux objects with a Runner capable of executing shell commands. +func New() *Tmux { + return &Tmux{Runner: &ExecRunner{}} +} + +// Exec runs the given tmux command. +func (s *Tmux) Exec(args ...string) ([]byte, error) { + args = append(s.Args(), args...) + + return s.Runner.Run(s.Binary(), args...) +} + +func (s *Tmux) Binary() string { + if s.BinPath != "" { + return s.BinPath + } else { + return "tmux" + } +} + +func (s *Tmux) Args() []string { + args := []string{} + + if s.SocketPath != "" { + args = append(args, "-S", s.SocketPath) + } else if s.SocketName != "" { + args = append(args, "-L", s.SocketName) + } + + return args +} + +func (s *Tmux) GetOptions(scope OptionsScope) (map[string]string, error) { + out, err := s.Exec("show-options", OptionsScopeFlags(scope)) + if err != nil { + return nil, err + } + + return s.parseOptions(out), nil +} + +func (s *Tmux) parseOptions(options []byte) map[string]string { + scanner := bufio.NewScanner(bytes.NewBuffer(options)) + result := map[string]string{} + + for scanner.Scan() { + match := optMatcher.FindSubmatch(scanner.Bytes()) + if len(match) > 2 { + result[string(match[1])] = string(s.unwrap(match[2], quote)) + } + } + + return result +} + +func (s *Tmux) unwrap(input, wrap []byte) []byte { + if bytes.HasPrefix(input, wrap) && bytes.HasSuffix(input, wrap) { + return bytes.TrimSuffix(bytes.TrimPrefix(input, wrap), wrap) + } + + return input +} diff --git a/tmux_test.go b/tmux_test.go new file mode 100644 index 0000000..c909956 --- /dev/null +++ b/tmux_test.go @@ -0,0 +1,224 @@ +package tmux + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockRunner struct { + mock.Mock +} + +func (s *MockRunner) Run(command string, args ...string) ([]byte, error) { + called := s.Called(append([]string{command}, args...)) + + return called.Get(0).([]byte), called.Error(1) +} + +func TestNewTmux(t *testing.T) { + tmux := New() + + assert.IsType(t, &Tmux{}, tmux) + assert.IsType(t, &ExecRunner{}, tmux.Runner) +} + +func TestTmuxExec(t *testing.T) { + tests := []struct { + binPath string + socketName string + socketPath string + baseArgs []string + args []string + out []byte + error error + }{ + { + args: []string{"new-session", "-d"}, + baseArgs: []string{"tmux"}, + }, + { + args: []string{"new-session", "-d"}, + baseArgs: []string{"tmux"}, + }, + { + args: []string{"list-sessions"}, + baseArgs: []string{"tmux"}, + out: []byte("0: 1 windows (created Fri Dec 6 23:45:19 2019)"), + }, + { + binPath: "/opt/tmux/bin/tmux", + args: []string{"list-sessions"}, + baseArgs: []string{"/opt/tmux/bin/tmux"}, + }, + { + binPath: "/opt/tmux/bin/tmux", + socketName: "test-sock", + args: []string{"list-sessions"}, + baseArgs: []string{"/opt/tmux/bin/tmux", "-L", "test-sock"}, + }, + { + binPath: "/opt/tmux/bin/tmux", + socketPath: "/tmp/tmux.sock", + args: []string{"list-sessions"}, + baseArgs: []string{"/opt/tmux/bin/tmux", "-S", "/tmp/tmux.sock"}, + }, + { + binPath: "/opt/tmux/bin/tmux", + socketName: "test-sock", + socketPath: "/tmp/tmux.sock", + args: []string{"list-sessions"}, + baseArgs: []string{"/opt/tmux/bin/tmux", "-S", "/tmp/tmux.sock"}, + }, + { + args: []string{"new-session", "-d"}, + baseArgs: []string{"tmux"}, + error: errors.New("Something went wrong"), + }, + } + + for _, tt := range tests { + runner := new(MockRunner) + runner.On("Run", append(tt.baseArgs, tt.args...)). + Return(tt.out, tt.error) + + tmux := Tmux{ + Runner: runner, + BinPath: tt.binPath, + SocketName: tt.socketName, + SocketPath: tt.socketPath, + } + + out, err := tmux.Exec(tt.args...) + + if tt.error == nil { + assert.NoError(t, err) + } else { + assert.Equal(t, tt.error, err) + } + + if tt.out != nil { + assert.Equal(t, tt.out, out) + } + + runner.AssertExpectations(t) + } +} + +func TestTmuxBinary(t *testing.T) { + tests := []struct { + binPath string + executable string + }{ + {binPath: "/opt/tmux/bin/tmux", executable: "/opt/tmux/bin/tmux"}, + {executable: "tmux"}, + } + + for _, tt := range tests { + tmux := &Tmux{} + tmux.BinPath = tt.binPath + + assert.Equal(t, tt.executable, tmux.Binary()) + } +} + +func TestTmuxArgs(t *testing.T) { + tests := []struct { + socketName string + socketPath string + args []string + }{ + {args: []string{}}, + {socketName: "foo", args: []string{"-L", "foo"}}, + {socketPath: "/tmp/bar", args: []string{"-S", "/tmp/bar"}}, + { + socketName: "foo", + socketPath: "/tmp/bar", + args: []string{"-S", "/tmp/bar"}, + }, + } + + for _, tt := range tests { + tmux := &Tmux{} + tmux.SocketName = tt.socketName + tmux.SocketPath = tt.socketPath + + assert.Equal(t, tt.args, tmux.Args()) + } +} + +func TestTmuxGetOptions(t *testing.T) { + tests := []struct { + flags string + scope OptionsScope + opts map[string]string + out []byte + error error + }{ + { + opts: map[string]string{"hello-world": "FooBar"}, + out: []byte(`hello-world FooBar`), + }, + { + scope: Server, + flags: "-s", + opts: map[string]string{"hello-world": "Foo Bar"}, + out: []byte(`hello-world "Foo Bar"`), + }, + { + scope: GlobalSession, + flags: "-g", + opts: map[string]string{"hello-world": "Foo Bar"}, + out: []byte(`hello-world "Foo Bar"`), + }, + { + scope: GlobalWindow, + flags: "-gw", + opts: map[string]string{"hello-world": " Foo Bar "}, + out: []byte(`hello-world " Foo Bar "`), + }, + { + scope: Window, + flags: "-w", + opts: map[string]string{"@foo": "bar"}, + out: []byte(`@foo bar`), + }, + { + opts: map[string]string{ + "@foo": "bar", + "@themepack": "powerline/default/green", + "status-left": "This Is Left", + }, + out: []byte(` + @foo bar +@themepack "powerline/default/green" +status-left This Is Left +`), + }, + } + + for _, tt := range tests { + if tt.scope == 0 { + tt.scope = Session + } + + runner := new(MockRunner) + runner.On("Run", append([]string{"tmux", "show-options"}, tt.flags)). + Return(tt.out, tt.error) + + tmux := Tmux{Runner: runner} + + opts, err := tmux.GetOptions(tt.scope) + + if tt.error == nil { + assert.NoError(t, err) + assert.Equal(t, tt.opts, opts) + } else { + assert.Equal(t, tt.error, err) + } + + runner.AssertExpectations(t) + } +}