├── .flow.yml ├── README.md ├── lib ├── __init__.py ├── cli.py ├── flow.py └── runners.py └── plugin └── vim-flow.vim /.flow.yml: -------------------------------------------------------------------------------- 1 | --- 2 | default: 3 | cmd: | 4 | #!/bin/bash 5 | clear 6 | set -e 7 | echo -e "unable to find a flow for {{filepath}} ..." 8 | echo -e "attempting to execute the file directly ..." 9 | {{filepath}} 10 | 11 | # fmt, build and execute golang files 12 | go: 13 | cmd: | 14 | #!/bin/bash 15 | clear 16 | set -e 17 | echo "running gofmt for $(basename {{filepath}}) ..." 18 | gofmt -d {{filepath}} 19 | echo "building $(basename {{filepath}}) ..." 20 | go build -o /tmp/$(basename {{filepath}}) {{filepath}} 21 | echo "executing $(basename {{filepath}})" 22 | /tmp/$(basename {{filepath}}) 23 | 24 | # build and execute rust source files 25 | rs: 26 | cmd: | 27 | #!/bin/bash 28 | clear 29 | set -e 30 | echo "compiling $(basename {{filepath}}) ..." 31 | rustc {{filepath}} -o /tmp/$(basename {{filepath}}) 32 | echo "executing $(basename {{filepath}}) ..." 33 | /tmp/$(basename {{filepath}}) 34 | 35 | # flake8 and execute python source files 36 | py: 37 | cmd: | 38 | #!/bin/bash 39 | clear 40 | set -e 41 | echo "running flake8 ..." 42 | flake8 {{filepath}} 43 | echo "executing $(basename {{filepath}}) ...". 44 | /usr/bin/env python {{filepath}} 45 | 46 | # ruby files 47 | rb: 48 | cmd: | 49 | #!/bin/bash 50 | clear 51 | set -e 52 | echo "executing $(basename {{filepath}}) ..." 53 | ruby {{filepath}} 54 | 55 | # load the yaml document in python and make sure it is parseable without errors 56 | yml: 57 | cmd: | 58 | #!/bin/bash 59 | clear 60 | set -e 61 | echo "loading yaml and parsing it in python to make sure its valid ..." 62 | /usr/bin/env python << EOF 63 | import yaml 64 | try: 65 | with open('{{filepath}}', 'r') as fh: 66 | yaml.load(fh.read()) 67 | except yaml.YAMLError: 68 | print "invalid yaml ..." 69 | else: 70 | print "valid yaml..." 71 | EOF 72 | 73 | # parse the json document using python and make sure it is valid 74 | json: 75 | cmd: | 76 | #!/bin/bash 77 | clear 78 | set -e 79 | echo "loading json and parsing it in python to ensure it is valid ..." 80 | /usr/bin/env python << EOF 81 | import json 82 | try: 83 | with open('{{filepath}}', 'r') as fh: 84 | json.loads(fh.read()) 85 | except ValueError as e: 86 | print "invalid json ..." 87 | else: 88 | print "valid json ..." 89 | EOF 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vim Flow 2 | 3 | Declarative build-test-run workflow with Vim. 4 | 5 | ```yaml 6 | #.flow.yaml 7 | go: 8 | cmd: go run {{filepath}} 9 | ``` 10 | 11 | ![image](https://cdn.pbrd.co/images/VD58yLW.gif) 12 | 13 | VimFlow keeps you in your editor and productive allowing you to run, test or format your current project with a single command. By exposing a simple interface and allowing you to define run commands in whatever shell language you are comfortable with, you can easily configure projects and scripts to your liking. 14 | 15 | Some common use cases for vim-flow are: 16 | 17 | * running your favorite one off script executor of choice eg: bash|python|ruby|go run etc... 18 | * running a code formatter / test suite for a large project you are working on 19 | * running any arbitrary script at any given time from vim while working 20 | 21 | ## Flow Run 22 | 23 | The `:FlowRun` command is responsible for executing anything and everything for a buffer within vim. Behind the scenes, `:FlowRun` will fetch the current filename and find a `.flow.yml` file to run. `vim-flow` starts at the current buffer's directory and walks upwards looking for the first `.flow.yml` file it finds. 24 | 25 | Let's say you are working on `main.go` and have the following directory structure: 26 | 27 | ```bash 28 | . 29 | ├── .flow.yml 30 | └── main.go 31 | ``` 32 | 33 | and your `.flow.yml` file contains the following: 34 | 35 | ```yaml 36 | # .flow.yml 37 | go: 38 | cmd: goimports -w {{filepath}} && go run {{filepath}} 39 | 40 | ``` 41 | 42 | `:FlowRun` will load in the local `.flow.yml`, match up and find the script `go imports {{filepath}} && go run {{filepath}}` by extension (.go) and will run that in the current vim window. 43 | 44 | 45 | `cmd` can be any command you can run in your terminal! Behind the scenes, `vim-flow` templates this out into a temporary file script and runs that script as part of its execution. 46 | 47 | Here's a more complicated example: 48 | 49 | ```bash 50 | . 51 | |-- .flow.yml 52 | |-- benchmark_test.go 53 | └── main.go 54 | ``` 55 | 56 | ```yaml 57 | # .flow.yml 58 | go: 59 | cmd: | #!/bin/bash 60 | cd $(dirname {{filepath}}) 61 | set -e 62 | goimports -w . 63 | go test -v . 64 | go test -bench=. 65 | GOOS=linux GOARCH=386 go build server.go 66 | ``` 67 | 68 | Behind the scenes, `flow` would write the entire contents of the `cmd` out to a temporary script and execute it as a bash script. In this particular example, you could run a formatter, test suite, benchmark suite and a build job all with one vim command. 69 | 70 | ## Flow Lock 71 | 72 | Part of the `flow` workflow is staying in your editor, popping around and changing filepaths frequently. Let's say you are working on a large project with many different files and build jobs: 73 | 74 | In this example, you may want to have different flows for the `gopackage` directory, some bash scripts in `/scripts` and even the `bins/main.go` file. 75 | 76 | ```bash 77 | . 78 | ├── .flow.yml 79 | ├── bins 80 | │   └── main.go 81 | ├── gopackage 82 | │   ├── .flow.yml 83 | │   ├── gopackage.go 84 | │   └── gopackage_test.go 85 | └── scripts 86 | ├── build 87 | └── test 88 | 89 | ``` 90 | 91 | You may also have an editor open with many of these buffers open. While you are working in `gopackage` you may want to build the entire project. 92 | 93 | `vim-flow` supports the ability to "lock" onto a file. By running `:FlowToggleLock` you can lock onto a file and any future calls to `:FlowRun` will use that file. This allows you to pop around to many different files while still calling the flow that you'd like to. 94 | 95 | Let's say we have the following flow, where you want to run `go build` for any go file and simply want to execute any other file by itself. 96 | 97 | ```bash 98 | default: 99 | cmd: {{filepath}} 100 | 101 | go: 102 | cmd: | #!/bin/bash 103 | cd $(dirname {{filepath}}) 104 | go build . 105 | 106 | ``` 107 | 108 | By locking onto `main.go` with `:FlowToggleLock` in your vim session, whenever you call `:FlowRun` `flow` will run the `go` flow. This allows you to switch to other files, buffers around your work space and still run the flow you'd like. 109 | 110 | To unlock a file, simply call `:FlowToggleLock` again. 111 | 112 | ## Tmux Support 113 | 114 | `vim-flow` supports running commands in a separate **tmux** session and pane. For longer builds or when you'd like to avoid interrupting your vim session, you can specify this in a flow file: 115 | 116 | ```yaml 117 | # .flow.yaml 118 | go: 119 | tmux_session: foo 120 | tmux_pane: 1 121 | cmd: | #!/bin/bash 122 | echo "executing go script in another tmux pane" 123 | go run {{filepath}} 124 | ``` 125 | 126 | ![tmux example](https://cdn.pbrd.co/images/VFLAzcF.gif) 127 | 128 | ## Installation 129 | 130 | `vim-flow` can be installed by simply adding it as a dependency using `vundle`: 131 | 132 | ```vim 133 | Plugin 'jonmorehouse/vim-flow' 134 | ``` 135 | 136 | `vim-flow` requires `python` support within vim. If you aren't sure if you have `python` enabled in your current vim installation, the following command will return 1/0 for success failure. 137 | 138 | ```vim 139 | :echo has('python') 140 | ``` 141 | 142 | ## Getting Started 143 | 144 | You'll probably want to add some reasonable **flow** defaults. Its nice to be able to hit `:FlowRun` from any sort of "standard" one off script and it _just work_. 145 | 146 | Adding a `$HOME/.flow.yml` file with some reasonable defaults is a good starting point. 147 | 148 | ```yaml 149 | # $HOME/ 150 | 151 | # execute any plain script by itself 152 | default: 153 | cmd: {{filepath}} 154 | 155 | 156 | # run flake8 and python {{filepath}} for any python file 157 | py: 158 | cmd: |#!/bin/bash 159 | flake8 {{filepath}} 160 | python {{filepath}} 161 | 162 | # run go fmt and go run {{filepath}} for any golang file 163 | go: 164 | cmd: |#!/bin/bash 165 | go fmt -w {{filepath}} 166 | go run {{filepath}} 167 | 168 | ``` 169 | 170 | ## Customization 171 | 172 | VimFlow provides two commands out of the box: 173 | 174 | * `:FlowRun` - run a flow for the current file. 175 | * `:FlowToggleLock` - lock or unlock the current file 176 | 177 | By design, `vim-flow` doesn't have any opinions about how these commands should be run as part of your vim workflow. For instance, it might be helpful to map one or both of these commands to normal mode mappings to avoid having to call them from the vim command line each time. 178 | 179 | For example, to link `,` to `:FlowRun` and `l` to `:FlowToggleLock` you can add the following to your `$HOME/.vimrc` file: 180 | 181 | ```vim 182 | # $HOME/.vimrc 183 | map , :FlowRun 184 | map l :FlowToggleLock 185 | ``` 186 | 187 | ## FAQ 188 | 189 | > Why use python instead of entirely native vim-script? 190 | 191 | It's definitely possible to write the entirety of this plugin in vimscript, but I've found that it s more intuitive to use python. It's a pretty standard vim dependency and allows for testing (coming soon) and a better maintenance/development experience. The vimscript surface area is super minimal and can be found #[here](https://github.com/jonmorehouse/vim-flow/blob/master/plugin/vim-flow.vim). 192 | 193 | 194 | > Why not use foo plugin? 195 | 196 | Plugins exist for many different runtimes/languages etc in `vim`, but attempting to set up Vim for every unique project is tedious and requires many different plugins. With `vim-flow` you have a common interface to running, testing and developing within a project. 197 | 198 | By using shell script, you can do _more_ things the way you'd like. 199 | 200 | > Why a flow file? 201 | 202 | Well, yaml is pretty easy to update and it's an interesting idea to create flow files for the projects you work on. In future iterations, we may add support to run flows from outside of vim. 203 | 204 | -------------------------------------------------------------------------------- /lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonmorehouse/vim-flow/654c5cc62bd0374ee43ab25ae4102d47a682800f/lib/__init__.py -------------------------------------------------------------------------------- /lib/cli.py: -------------------------------------------------------------------------------- 1 | import vim 2 | import os 3 | 4 | import flow 5 | import runners 6 | 7 | lock_cache = {} 8 | 9 | def run_flow(cache=lock_cache): 10 | '''flow: run a flow for the current filepath 11 | 12 | * find and load flow defs 13 | * build cmd_def 14 | * run cmd_def 15 | ''' 16 | if 'filepath' in lock_cache: 17 | filepath = lock_cache['filepath'] 18 | 19 | dirpath = os.path.dirname(filepath) 20 | dirpath = os.path.expanduser(dirpath) 21 | os.chdir(dirpath) 22 | else: 23 | filepath = _get_filepath() 24 | flow_defs = flow.get_defs(filepath) 25 | if flow_defs is None: 26 | return 27 | 28 | cmd_def = flow.get_cmd_def(filepath, flow_defs) 29 | if cmd_def is None: 30 | return 31 | 32 | runner = { 33 | 'vim': runners.vim_runner, 34 | 'tmux': runners.tmux_runner, 35 | 'sync-remote': runners.sync_remote_runner, 36 | 'async-remote': runners.async_remote_runner, 37 | }[cmd_def['runner']] 38 | 39 | runner(cmd_def) 40 | 41 | 42 | def toggle_lock(filepath, cache=lock_cache): 43 | if filepath: 44 | cache['filepath'] = filepath 45 | return 46 | 47 | if 'filepath' in cache: 48 | del cache['filepath'] 49 | print("file lock released...") 50 | else: 51 | cache['filepath'] = _get_filepath() 52 | print("file lock set...") 53 | 54 | 55 | def _get_filepath(): 56 | return vim.current.buffer.name 57 | -------------------------------------------------------------------------------- /lib/flow.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import yaml 3 | 4 | 5 | def get_defs(filepath): 6 | '''get_flow_defs: returns the best matched flowfile and returns the flow_defs 7 | 8 | This method walks directories from the current filepath all the way to `/` 9 | and will return the first `.flow.yml` file it finds. 10 | 11 | ''' 12 | dirpath = os.path.dirname(filepath) 13 | flow_filepath = None 14 | while dirpath != '/': 15 | flow_filepath = os.path.join(dirpath, '.flow.yml') 16 | if os.path.exists(flow_filepath): 17 | break 18 | dirpath = os.path.abspath(os.path.join(dirpath, '../')) 19 | else: 20 | print('No `.flow.yml` found...') 21 | return None 22 | 23 | try: 24 | with open(flow_filepath, 'r') as fh: 25 | flow_defs = yaml.safe_load(fh) 26 | except IOError: 27 | print('`flow.yml` file at %s appears to be non-readable from within vim' % flow_filepath) 28 | except yaml.YAMLError: 29 | print('`flow.yml` file at %s is not parseable yaml' % flow_filepath) 30 | else: 31 | return flow_defs 32 | 33 | return None 34 | 35 | 36 | def _format_cmd_def(cmd_def, filepath): 37 | '''_format_cmd_def: format a command def 38 | 39 | * template `filepath` into the cmd string 40 | * add the runner field 41 | ''' 42 | _dir = os.path.dirname(filepath) 43 | templates = { 44 | '{{filepath}}': filepath, 45 | '{{dir}}': _dir, 46 | } 47 | for keyword, value in templates.items(): 48 | cmd_def['cmd'] = cmd_def['cmd'].replace(keyword, value) 49 | cmd_def['cmd'] = cmd_def['cmd'].strip() 50 | 51 | if 'runner' not in cmd_def: 52 | if 'tmux_session' in cmd_def: 53 | cmd_def['tmux_pane'] = cmd_def.get('tmux_pane', 0) 54 | cmd_def['runner'] = 'tmux' 55 | else: 56 | cmd_def['runner'] = 'vim' 57 | 58 | cmd_def['runner'] = cmd_def['runner'].replace('_', '-') 59 | if cmd_def['runner'] not in ('vim', 'tmux', 'async-remote', 'sync-remote'): 60 | print('invalid runner, must be one of vim,tmux,async-remote,sync-remote') 61 | return 62 | 63 | if 'cmd' in cmd_def and not cmd_def['cmd'].startswith('#!'): 64 | cmd_def['cmd'] = '#!/usr/bin/env bash\n' + cmd_def['cmd'] 65 | 66 | return cmd_def 67 | 68 | 69 | def get_cmd_def(filepath, flow_defs): 70 | '''find_cmd: returns a cmd_def based upon the flow_defs and filepath 71 | 72 | return { 73 | 'runner': 'string | vim|tmux', 74 | 'tmux_sesion': 'string | tmux_session', 75 | 'tmux_pane': 'int | tmux_pane', 76 | 'cmd': 'string, command to be executed', 77 | } 78 | ''' 79 | basename = os.path.basename(filepath) 80 | filename, ext = os.path.splitext(basename) 81 | 82 | cmd_def = flow_defs.get('default') 83 | if basename in flow_defs: 84 | cmd_def = flow_defs[basename] 85 | elif filename in flow_defs: 86 | cmd_def = flow_defs[filename] 87 | elif ext in flow_defs: 88 | cmd_def = flow_defs[ext] 89 | elif ext.replace('.', '') in flow_defs: 90 | cmd_def = flow_defs[ext.replace('.', '')] 91 | 92 | if cmd_def is None: 93 | print('no valid command definitions found in `.flow.yml`. Try adding an extension or `all` def...') 94 | return None 95 | 96 | return _format_cmd_def(cmd_def, filepath) 97 | -------------------------------------------------------------------------------- /lib/runners.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import glob 3 | import os 4 | import subprocess 5 | import stat 6 | import time 7 | import tempfile 8 | import urllib.parse 9 | import vim 10 | 11 | import requests 12 | 13 | 14 | def _build_script(cmd_def): 15 | '''_build_script: in order to simplify things such as escaping and 16 | multiline commands, we template out all `cmds` into a script that is 17 | written to a tempfile and executed 18 | ''' 19 | filepath = '/tmp/flow--{}'.format(int(time.time())) 20 | print(filepath) 21 | 22 | # if the script doesn't start with a hashbang, then we default to the local $SHELL var 23 | with open(filepath, 'w') as fh: 24 | if not cmd_def['cmd'].startswith('#!'): 25 | hashbang = '#!{}\n'.format(os.environ.get('SHELL', '/usr/bin/env bash')) 26 | fh.write(hashbang) 27 | 28 | fh.write(cmd_def['cmd']) 29 | fh.write('\n') 30 | 31 | st = os.stat(filepath) 32 | os.chmod(filepath, st.st_mode | stat.S_IEXEC) 33 | return filepath 34 | 35 | 36 | def cleanup(): 37 | '''cleanup: due to the tmux send keys command being asynchronous, we can not guarantee when a command is finished 38 | and therefore can not clean up after ourselves consistently. 39 | ''' 40 | for filepath in glob.glob("/tmp/flow--*"): 41 | os.remove(filepath) 42 | 43 | 44 | @contextlib.contextmanager 45 | def _script(cmd_def): 46 | '''_script: a context manager for creating a command script and cleaning it up 47 | 48 | usage: 49 | with _script(cmd_def) as script_filepath: 50 | print script_filepath 51 | ''' 52 | filepath = _build_script(cmd_def) 53 | yield filepath 54 | 55 | 56 | def vim_runner(cmd_def): 57 | '''vim_runner: run a command in the vim tty 58 | ''' 59 | cleanup() 60 | with _script(cmd_def) as script_path: 61 | vim.command('! {}'.format(script_path)) 62 | 63 | 64 | def tmux_runner(cmd_def): 65 | '''tmux_runner: accept a command definition and then run it as a shell script in the tmux session.pane. 66 | ''' 67 | cleanup() 68 | with _script(cmd_def) as script: 69 | args = ['tmux', 70 | 'send', 71 | '-t', 72 | '%s.%s' % (cmd_def['tmux_session'], cmd_def['tmux_pane']), 73 | 'sh -c \'%s\'' % script, 74 | 'ENTER'] 75 | 76 | env = os.environ.copy() 77 | process = subprocess.Popen(args, env=env) 78 | process.wait() 79 | 80 | 81 | def async_remote_runner(cmd_def): 82 | '''async_remote_runner: run the command against a vim-flow remote 83 | ''' 84 | base_url = vim.eval('g:vim_flow_remote_address') 85 | if not base_url.startswith('http'): 86 | base_url = 'http://' + url 87 | url = urllib.parse.urljoin(base_url, 'async') 88 | 89 | try: 90 | resp = requests.post(url, data=cmd_def['cmd']) 91 | except Exception: 92 | vim.command('echom "vim-flow: unable to submit job: {}"'.format(url)) 93 | return 94 | 95 | vim.command('echom "vim-flow: async job submitted {}"'.format(resp.status_code)) 96 | 97 | 98 | def sync_remote_runner(cmd_def): 99 | '''sync_remote_runner: run the command against a vim-flow remote 100 | ''' 101 | vim.command('echom "vim-flow: {}"'.format(res)) 102 | -------------------------------------------------------------------------------- /plugin/vim-flow.vim: -------------------------------------------------------------------------------- 1 | " NOTE: we cannot do hot reloading without dynamically reloading python3 modules 2 | if exists("g:vim_flow_loaded") || &cp 3 | finish 4 | endif 5 | let g:vim_flow_loaded = 1 6 | 7 | let g:vim_flow_remote_address = "http://localhost:7000" 8 | 9 | " make sure that vim is compiled with correct python2.7 suppor 10 | if !has("python3") 11 | echo "vim-flow requires python3 support" 12 | finish 13 | endif 14 | 15 | python3 <:p:h')"), "../lib")) 21 | sys.path.insert(0, lib_path) 22 | 23 | import cli 24 | EOF 25 | 26 | " run flow for the current window 27 | command! FlowRun :python3 cli.run_flow("") 28 | 29 | " toggle lock on / off for current file 30 | command! FlowToggleLock :python3 cli.toggle_lock("") 31 | 32 | command! -nargs=1 FlowSet :python3 cli.toggle_lock() 33 | 34 | function! FlowSetFile(filename) 35 | :python3 cli.toggle_lock(vim.eval("a:filename")) 36 | endfunction 37 | --------------------------------------------------------------------------------