From 77dad739ce4dbea76702e4865e6ae3cc39878e54 Mon Sep 17 00:00:00 2001
From: Noah Frederick <noah@noahfrederick.com>
Date: Fri, 11 Nov 2016 11:06:03 -0500
Subject: [PATCH] Provide :Homestead command

Interact with the current project on a Homestead VM from the host
machine over SSH. See :help :Homestead for usage.
---
 README.md                      |   1 +
 autoload/laravel.vim           |  54 +++++++++++++-
 autoload/laravel/homestead.vim | 130 +++++++++++++++++++++++++++++++++
 doc/laravel.txt                |  56 +++++++++++++-
 plugin/laravel.vim             |   7 ++
 test/homestead.vader           |  20 +++++
 6 files changed, 265 insertions(+), 3 deletions(-)
 create mode 100644 autoload/laravel/homestead.vim
 create mode 100644 test/homestead.vader

diff --git a/README.md b/README.md
index c494516..9963aaa 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ Vim support for [Laravel/Lumen][laravel] projects.
 * Navigation commands such as `:Econtroller`, `:Eroutes`, `:Etest` and [many more][wiki-navigation].
 * Enhanced `gf` command works on class names, template names, config and translation keys.
 * Complete view/route names in insert mode.
+* Interact with a Homestead guest VM from the host machine using `:Homestead`.
 * Use `:Console` to fire up a REPL (`artisan tinker`).
 * Use `:Start` to serve the app locally (`artisan serve`).
 
diff --git a/autoload/laravel.vim b/autoload/laravel.vim
index d9abd78..250dd89 100644
--- a/autoload/laravel.vim
+++ b/autoload/laravel.vim
@@ -149,8 +149,20 @@ function! s:app_views_path(...) dict abort
   return join([self._root, 'resources/views'] + a:000, '/')
 endfunction
 
+""
+" Get path to source root on the Homestead VM, optionally with [path] appended.
+function! s:app_homestead_path(...) dict abort
+  if self.cache.needs('homestead_root')
+    call self.cache.set('homestead_root', laravel#homestead#root(self._root))
+  endif
+
+  let root = self.cache.get('homestead_root')
+
+  return empty(root) ? '' : join([root] + a:000, '/')
+endfunction
+
 call s:add_methods('app', ['glob', 'has_dir', 'has_file', 'has_path'])
-call s:add_methods('app', ['path', 'src_path', 'config_path', 'migrations_path', 'find_migration', 'expand_migration', 'views_path'])
+call s:add_methods('app', ['path', 'src_path', 'config_path', 'migrations_path', 'find_migration', 'expand_migration', 'views_path', 'homestead_path'])
 
 ""
 " Detect app's namespace
@@ -565,6 +577,46 @@ function! laravel#buffer_commands() abort
   " Invoke Artisan with [arguments] (with intelligent completion).
   command! -buffer -bang -bar -nargs=* -complete=customlist,laravel#artisan#complete
         \ Artisan execute laravel#artisan#exec(<q-bang>, <f-args>)
+
+  ""
+  " @command Homestead {cmd}
+  " Invoke shell {cmd} on the Homestead VM over SSH.
+  "
+  " Several strategies for executing the ssh command in order:
+  "
+  " * Dispatch's |:Start| command
+  " * The built-in |:terminal|
+  " * At Vim's command line via |:!|
+  "
+  " The {cmd} is executed with the working directory being the project's
+  " directory on the VM. The project's directory is detected from your
+  " Homestead.json configuration file, using the "folders" mappings to do the
+  " translation from host path to guest path: >
+  "     "folders": [
+  "         {
+  "             "map": "~/code",
+  "             "to": "/home/vagrant/code"
+  "         }
+  "     ],
+  " <
+  "
+  " The plug-in will look for the Homestead.json file in the directory
+  " specified in @setting(laravel_homestead_dir) or in ~/Homestead.
+  "
+  " Note: Only Homestead.json is taken into account, and not Homestead.yaml,
+  " since Vim cannot parse YAML. If you prefer to use the Homestead.yaml file,
+  " it's sufficient to set only the "folders" array in Homestead.json.
+  "
+  " @command Homestead
+  " Start an interactive SSH session on the Homestead VM.
+  "
+  " @command Homestead! [arguments]
+  " Invoke Vagrant with [arguments] in the context of the Homestead directory
+  " on the host machine. For example, to start the VM: >
+  "     :Homestead! up
+  " <
+  command! -buffer -bang -bar -nargs=* -complete=shellcmd
+        \ Homestead execute laravel#homestead#exec(<q-bang>, <f-args>)
 endfunction
 
 ""
diff --git a/autoload/laravel/homestead.vim b/autoload/laravel/homestead.vim
new file mode 100644
index 0000000..6124b8a
--- /dev/null
+++ b/autoload/laravel/homestead.vim
@@ -0,0 +1,130 @@
+" autoload/laravel/homestead.vim - Laravel Homestead support for Vim
+" Maintainer: Noah Frederick
+
+""
+" The directory where Homestead is installed.
+let s:dir = get(g:, 'laravel_homestead_dir', '~/Homestead')
+let s:yaml = s:dir . '/Homestead.yaml'
+let s:json = s:dir . '/Homestead.json'
+
+""
+" Get Dict from JSON {expr}.
+function! s:json_decode(expr) abort
+  try
+    if exists('*json_decode')
+      let expr = type(a:expr) == type([]) ? join(a:expr, "\n") : a:expr
+      return json_decode(expr)
+    else
+      return projectionist#json_parse(a:expr)
+    endif
+  catch /^Vim\%((\a\+)\)\=:E474/
+    call laravel#error('Homestead.json cannot be parsed')
+  catch /^invalid JSON/
+    call laravel#error('Homestead.json cannot be parsed')
+  catch /^Vim\%((\a\+)\)\=:E117/
+    call laravel#error('projectionist is not available')
+  endtry
+  return {}
+endfunction
+
+""
+" Get path to current project on the Homestead VM.
+function! laravel#homestead#root(app_root) abort
+  if !filereadable(s:json)
+    call laravel#error('Homestead.json cannot be read: '
+          \ . s:json . ' (set g:laravel_homestead_dir)')
+    return ''
+  endif
+
+  let config = s:json_decode(readfile(s:json))
+
+  for folder in get(config, 'folders', [])
+    let source = expand(folder.map)
+
+    if a:app_root . '/' =~# '^' . source . '/'
+      return substitute(a:app_root, '^' . source, folder.to, '')
+    endif
+  endfor
+
+  return ''
+endfunction
+
+""
+" Change working directory to {dir}, respecting current window's local dir
+" state. Returns old working directory to be restored later by a second
+" invocation of the function.
+function! s:cd(dir) abort
+  let cd = exists('*haslocaldir') && haslocaldir() ? 'lcd' : 'cd'
+  let cwd = getcwd()
+  execute cd fnameescape(a:dir)
+  return cwd
+endfunction
+
+""
+" Build SSH shell command from command-line arguments.
+function! s:ssh(args) abort
+  if empty(a:args)
+    return 'vagrant ssh'
+  endif
+
+  let root = laravel#app().homestead_path()
+
+  if empty(root)
+    call laravel#error('Homestead site not configured for '
+          \ . laravel#app().path())
+    return ''
+  endif
+
+  let args = insert(a:args, 'cd ' . fnamemodify(root, ':S') . ' &&')
+  return 'vagrant ssh -- ' . shellescape(join(args))
+endfunction
+
+""
+" Build Vagrant shell command from command-line arguments.
+function! s:vagrant(args) abort
+  let args = empty(a:args) ? ['status'] : a:args
+  return 'vagrant ' . join(args)
+endfunction
+
+""
+" The :Homestead command.
+function! laravel#homestead#exec(...) abort
+  let args = copy(a:000)
+  let vagrant = remove(args, 0)
+
+  if !isdirectory(s:dir)
+    return laravel#error('Homestead directory does not exist: '
+          \ . s:dir . ' (set g:laravel_homestead_dir)')
+  endif
+
+  let cmdline = vagrant ==# '!' ? s:vagrant(args) : s:ssh(args)
+
+  if empty(cmdline)
+    " There is no path configured for the VM.
+    return ''
+  endif
+
+  if exists(':Start')
+    execute 'Start -title=homestead -wait=always -dir='.fnameescape(s:dir) cmdline
+  elseif exists(':terminal')
+    tab split
+    execute 'lcd' fnameescape(s:dir)
+    execute 'terminal' cmdline
+  else
+    let cwd = s:cd(s:dir)
+    execute '!' . cmdline
+    call s:cd(cwd)
+  endif
+
+  return ''
+endfunction
+
+""
+" @private
+" Hack for testing script-local functions.
+function! laravel#homestead#sid()
+  nnoremap <SID> <SID>
+  return maparg('<SID>', 'n')
+endfunction
+
+" vim: fdm=marker:sw=2:sts=2:et
diff --git a/doc/laravel.txt b/doc/laravel.txt
index e5f773e..0def5f9 100644
--- a/doc/laravel.txt
+++ b/doc/laravel.txt
@@ -4,8 +4,9 @@ Noah Frederick                                         *Laravel.vim* *laravel*
 ==============================================================================
 CONTENTS                                                    *laravel-contents*
   1. Introduction..............................................|laravel-intro|
-  2. Commands...............................................|laravel-commands|
-  3. About.....................................................|laravel-about|
+  2. Configuration............................................|laravel-config|
+  3. Commands...............................................|laravel-commands|
+  4. About.....................................................|laravel-about|
 
 ==============================================================================
 INTRODUCTION                                                   *laravel-intro*
@@ -15,16 +16,67 @@ Some features include:
 
   * The |:Artisan| command wraps artisan with intelligent completion.
   * Includes projections for projectionist.vim.
+  * Use |:Homestead| to send commands over SSH to your development VM.
   * Use |:Console| to fire up a REPL (artisan tinker).
 
 This plug-in is only available if 'compatible' is not set.
 
+==============================================================================
+CONFIGURATION                                                 *laravel-config*
+
+                                                     *g:laravel_homestead_dir*
+The directory where Homestead is installed. Default:
+>
+    '~/Homestead'
+<
+
 ==============================================================================
 COMMANDS                                                    *laravel-commands*
 
 :Artisan[!] [arguments]                                             *:Artisan*
   Invoke Artisan with [arguments] (with intelligent completion).
 
+:Homestead {cmd}                                                  *:Homestead*
+  Invoke shell {cmd} on the Homestead VM over SSH.
+
+  Several strategies for executing the ssh command in order:
+
+    * Dispatch's |:Start| command
+    * The built-in |:terminal|
+    * At Vim's command line via |:!|
+
+  The {cmd} is executed with the working directory being the project's
+  directory on the VM. The project's directory is detected from your
+  Homestead.json configuration file, using the "folders" mappings to do the
+  translation from host path to guest path:
+>
+      "folders": [
+          {
+              "map": "~/code",
+              "to": "/home/vagrant/code"
+          }
+      ],
+<
+
+  The plug-in will look for the Homestead.json file in the directory specified
+  in |g:laravel_homestead_dir| or in ~/Homestead.
+
+  Note: Only Homestead.json is taken into account, and not Homestead.yaml,
+  since Vim cannot parse YAML. If you prefer to use the Homestead.yaml file,
+  it's sufficient to set only the "folders" array in Homestead.json.
+
+
+:Homestead
+  Start an interactive SSH session on the Homestead VM.
+
+
+:Homestead! [arguments]
+  Invoke Vagrant with [arguments] in the context of the Homestead directory on
+  the host machine. For example, to start the VM:
+>
+      :Homestead! up
+<
+
 ==============================================================================
 ABOUT                                                          *laravel-about*
 
diff --git a/plugin/laravel.vim b/plugin/laravel.vim
index 69d8c6e..05d2e72 100644
--- a/plugin/laravel.vim
+++ b/plugin/laravel.vim
@@ -9,10 +9,17 @@
 "
 " * The |:Artisan| command wraps artisan with intelligent completion.
 " * Includes projections for projectionist.vim.
+" * Use |:Homestead| to send commands over SSH to your development VM.
 " * Use |:Console| to fire up a REPL (artisan tinker).
 "
 " This plug-in is only available if 'compatible' is not set.
 
+""
+" @setting g:laravel_homestead_dir
+" The directory where Homestead is installed. Default: >
+"     '~/Homestead'
+" <
+
 ""
 " @section About, about
 " @plugin(stylized) is distributed under the same terms as Vim itself (see
diff --git a/test/homestead.vader b/test/homestead.vader
new file mode 100644
index 0000000..4dc44a1
--- /dev/null
+++ b/test/homestead.vader
@@ -0,0 +1,20 @@
+Before (in a laravel buffer):
+  let g:laravel_homestead_dir = expand('test/fixtures')
+  tabedit test/fixtures/laravel-8/.env
+
+After (clean up buffer):
+  bdelete
+
+Execute (Detect Homestead app root):
+  AssertEqual laravel#homestead#root('/home/local/code/project'), '/home/vagrant/code/project'
+
+Execute (Invalid Homestead app root):
+  AssertEqual laravel#homestead#root('/home/local/invalid/project'), ''
+
+Execute (Access Homestead app root via app object):
+  " Fake the app root.
+  let b:app = deepcopy(laravel#app())
+  let b:app._root = '/home/local/code/project'
+
+  AssertEqual b:app.homestead_path(), '/home/vagrant/code/project'
+  AssertEqual b:app.homestead_path('public'), '/home/vagrant/code/project/public'