Skip to content

Commit

Permalink
Improve shell integration
Browse files Browse the repository at this point in the history
  • Loading branch information
dhamidi committed Aug 14, 2018
1 parent 5b41c22 commit b34b4fd
Show file tree
Hide file tree
Showing 9 changed files with 196 additions and 20 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/*-packr.go
/leader
2 changes: 2 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ go:
- '1.10'
before_install:
- go get github.com/mitchellh/gox
- go get github.com/gobuffalo/packr/...
install:
- # skip
script:
- go get -t -v ./...
- go test -v -race ./...
- packr
- if [ -n "$TRAVIS_TAG" ]; then gox -os="linux darwin" -arch="amd64" -output="leader.{{.OS}}.{{.Arch}}" -ldflags "-X main.Rev=`git rev-parse --short HEAD`" -verbose ./...; fi
deploy:
provider: releases
Expand Down
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.PHONY: install
install: pack
go install .

.PHONY: pack
pack: $(GOPATH)/bin/packr
packr

$(GOPATH)/bin/packr:
go get -u github.com/gobuffalo/packr/...
27 changes: 8 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,30 +70,19 @@ Leader tries to load a file called `.leaderrc` from your current working directo

The closer a file is to your working directory, the more important keybindings in that file are. For example, binding `g b` to `go build .` in `~/.leaderrc` and to `gulp build` in `$HOME/projects/project-using-gulp` will make `leader` prefer running `gulp build` when in your frontend project's directory and `go build` elsewhere.

# BASH integration

To trigger `leader` when pressing `\` in bash, run the following command and add it to your bash initialization file:
# Installation

bind -x '"\\":leader'
bind -x '"\C-h":eval $(leader print)'
Download the `leader` binary from [here](https://github.com/dhamidi/leader/releases) and put it somewhere on your `$PATH`.

Now every time you press `\`, `leader` will be started.
Add the following to your `~/.bashrc` or `~/.zshrc`:

Pressing `C-h` also invokes `leader`, however instead of actually running a command `leader` prints it to stdout where the shell interprets it as a command to run. This is necessary for running commands that modify the current shell's state, such as `cd`.

# ZSH integration

To trigger `leader` when pressing `\` in zsh, run the following command and add it to your zsh initialization file:

bindkey -s '\\' "leader\C-j"
bindkey -s '\C-h' 'eval $(leader print)\C-j'

Now every time you press `\`, `leader` will be started.
```
eval "$(leader init)"
```

Pressing `C-h` also invokes `leader`, however instead of actually running a command `leader` prints it to stdout where the shell interprets it as a command to run. This is necessary for running commands that modify the current shell's state, such as `cd`.
This installs leader and binds it to `\`.

# Execution environment

All commands found in `.leaderrc` are currently passed as arguments to `bash -c`.

In order to support modifying the environment of the current shell, `leader` also supports _printing_ commands instead of running them.
All commands triggered by leader are run in the context of the current shell. This means that `cd`, `pushd` and other commands that modify the state of the current shell work without problems in your `.leaderrc`.
15 changes: 15 additions & 0 deletions assets/leader.bash.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/bin/bash

leader_widget() {
local leader_exit leader_next
leader_next=$(leader "$@")
leader_exit=$?
if [ "$leader_exit" = 3 ]; then
READLINE_LINE="${READLINE_LINE}\\"
READLINE_POINT=$((READLINE_POINT + 1))
return $leader_exit
fi
eval "$leader_next"
}

bind -x '"\\":leader_widget print'
18 changes: 18 additions & 0 deletions assets/leader.zsh.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/zsh

() {
leader_widget() {
local leader_exit leader_next
leader_next=$(SHELL=/bin/zsh BUFFER=$BUFFER CURSOR=$CURSOR leader print)
leader_exit=$?
if [ $leader_exit -eq 3 ]; then
BUFFER="${BUFFER}${KEYS}"
CURSOR=$((CURSOR + $#KEYS))
return "$leader_exit"
fi
eval "$leader_next"
}

zle -N leader_widget
bindkey '\\' leader_widget
}
59 changes: 58 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
package main

import "os"
import (
"fmt"
"os"
"path/filepath"
"strconv"

"github.com/gobuffalo/packr"
)

func main() {
errorHandler := NewErrorLogger(os.Stderr)
Expand All @@ -14,6 +21,13 @@ func main() {
if err != nil {
errorHandler.Fatal(err)
}

line, cursor := getCurrentInputLine()
shellParser := NewShellParser()
if shellParser.InQuotedString(line, cursor) {
os.Exit(3)
}

executor := NewShellExecutor("bash", "-c").Attach(tty.File())
rootKeyMap := NewKeyMap("root")
context := &Context{
Expand All @@ -34,6 +48,12 @@ func parseArgs(context *Context, args []string) {
if len(args) == 1 {
return
}

if args[1] == "init" {
initShell()
os.Exit(0)
}

for i := 0; i < len(args); i++ {
if args[i] == "print" {
context.Executor = NewPrintingExecutor(context, os.Stdout)
Expand All @@ -59,3 +79,40 @@ func navigateTo(context *Context, path []rune) {
}

}

func getCurrentInputLine() (string, int) {
shellName := filepath.Base(os.Getenv("SHELL"))
switch shellName {
case "zsh":
return getCurrentInputLineZSH()
case "bash":
return getCurrentInputLineBash()
default:
return "", 0
}
}

func getCurrentInputLineZSH() (string, int) {
line := os.Getenv("BUFFER")
point, _ := strconv.Atoi(os.Getenv("CURSOR"))
return line, point
}

func getCurrentInputLineBash() (string, int) {
readlineLine := os.Getenv("READLINE_LINE")
readlinePoint, _ := strconv.Atoi(os.Getenv("READLINE_POINT"))
return readlineLine, readlinePoint
}

func initShell() {
initFiles := packr.NewBox("./assets")
shellName := filepath.Base(os.Getenv("SHELL"))
switch shellName {
case "zsh":
fmt.Printf("%s\n", initFiles.String("leader.zsh.sh"))
case "bash":
fmt.Printf("%s\n", initFiles.String("leader.bash.sh"))
default:
fmt.Fprintf(os.Stderr, "Shell %s not supported!\n", shellName)
}
}
39 changes: 39 additions & 0 deletions shell_parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package main

// ShellParser provides simple parsing methods to detect the lexical
// context in which leader is invoked as part of an interactive shell.
//
// Using ShellParser, leader can detect whether it should run or not.
type ShellParser struct{}

// NewShellParser creates a new parser instance.
func NewShellParser() *ShellParser {
return &ShellParser{}
}

// InQuotedString returns true if cursor points to a position in line that is within a quoted string.
func (p *ShellParser) InQuotedString(line string, cursor int) bool {
textUpToCursor := line[:cursor]
doubleQuotes := 0
singleQuotes := 0
escaped := false
for _, char := range []rune(textUpToCursor) {
switch char {
case '"':
if doubleQuotes == 0 {
doubleQuotes++
} else if !escaped {
doubleQuotes--
}
case '\'':
if singleQuotes == 0 {
singleQuotes++
} else {
singleQuotes--
}
case '\\':
escaped = !escaped
}
}
return doubleQuotes != 0 || singleQuotes != 0
}
45 changes: 45 additions & 0 deletions shell_parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package main_test

import (
"testing"

"github.com/dhamidi/leader"
"github.com/stretchr/testify/assert"
)

const (
printfDate = `printf "%s\n" "$(date)"`
escapedDoubleQuote = `printf "\"hello\"\n"`
printfDateSingle = `printf '%s\n' "$(date)"`
bindMixedQuotes = `bind -x '"\\":leader'`
)

func TestShellParser_InQuotedString_returns_true_if_cursor_is_inside_double_quotes(t *testing.T) {
parser := main.NewShellParser()
assert.True(t, parser.InQuotedString(printfDate, len(`printf "`)))
}

func TestShellParser_InQuotedString_returns_false_if_cursor_is_outside_of_double_quotes(t *testing.T) {
parser := main.NewShellParser()
assert.False(t, parser.InQuotedString(printfDate, len(`printf `)))
}

func TestShellParser_InQuotedString_returns_true_if_string_contains_escaped_double_quotes(t *testing.T) {
parser := main.NewShellParser()
assert.True(t, parser.InQuotedString(escapedDoubleQuote, len(`printf "\"h`)))
}

func TestShellParser_InQuotedString_returns_true_if_cursor_is_inside_single_quotes(t *testing.T) {
parser := main.NewShellParser()
assert.True(t, parser.InQuotedString(printfDateSingle, len(`printf '`)))
}

func TestShellParser_InQuotedString_returns_false_if_cursor_is_outside_of_single_quotes(t *testing.T) {
parser := main.NewShellParser()
assert.False(t, parser.InQuotedString(printfDateSingle, len(`printf `)))
}

func TestShellParser_InQuotedString_returns_true_if_cursor_is_inside_nested_quotes(t *testing.T) {
parser := main.NewShellParser()
assert.True(t, parser.InQuotedString(bindMixedQuotes, len(`bind -x '"\`)))
}

0 comments on commit b34b4fd

Please sign in to comment.