Skip to content

Commit d22d129

Browse files
committed
init POC version
0 parents  commit d22d129

29 files changed

+1386
-0
lines changed

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**/**/_build
2+
/cover
3+
/**/**/deps
4+
/tmp
5+
/spec/fixtures/xdg_home/nvim/plugin/*
6+
/spec/fixtures/xdg_home/nvim/rplugin/*
7+
erl_crash.dump
8+
*.ez
9+

README.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Neovim Elixir host
2+
3+
Implements support for Neovim remote plugins written in Elixir.
4+
5+
6+
## Installation
7+
8+
Assume you are already dealing with a working Elixir install.
9+
10+
Install the host archive, we will use it to build the host locally.
11+
12+
```
13+
$ mix archive.install https://github.com/dm1try/neovim_host/raw/master/nvim.ez
14+
```
15+
16+
Build and install the host by running `nvim.install` and providing the path to nvim config(`~/.config/nvim` by default on linux systems)
17+
18+
```
19+
$ mix nvim.install /path/to/nvim/config
20+
```
21+
22+
## Usage
23+
24+
Currently, each time you somehow update remote plugins you should run `:UpdateElixirPlugins` nvim command(the wrapper for `:UpdateRemotePlugins`). See `:h remote-plugins-manifest` for the clarification why the manifest is needed(generally, it saves the neovim startup time if remote plugins are installed).
25+
26+
# Plugin Development
27+
## Structure
28+
29+
Host supports two types of plugins:
30+
1. Scripts (an elixir script that usually contains simple logic and does not depend on other libs/does not need the versioning/etc).
31+
32+
2. Applications (an OTP application that is implemented as part of host umbrella project). You can find more information about umbrella projects [here](http://elixir-lang.org/getting-started/mix-otp/dependencies-and-umbrella-apps.html).
33+
34+
Host with plugins lives in `rplugin/elixir` of neovim config directory.
35+
Typical files tree for such dir:
36+
```bash
37+
~/.config/nvim/rplugin/elixir
38+
39+
├── scripts <~ scripts
40+
├── apps <~ applications AKA "precompiled plugins"
41+
└── mix.exs
42+
```
43+
44+
### Plugin DSL
45+
#### Events
46+
`on_event`(better known as `autocmd` for vim users) defines the callback that triggered by editor when some event
47+
happened. Run `:h autocmd-events` for the list of events.
48+
49+
```elixir
50+
on_event :vim_enter do
51+
Logger.info("the editor is ready")
52+
end
53+
```
54+
#### Functions
55+
`function` defines the vim function
56+
```
57+
function wrong_sum(left, right) do
58+
{:ok, left - right}
59+
end
60+
```
61+
use it in the editor `:echo WrongSum(1,2)`
62+
63+
#### Commands
64+
`command` defines the command.
65+
66+
```
67+
command just_echo do
68+
NVim.Session.vim_command("echo from remote plugin")
69+
end
70+
```
71+
use it in the editor `:JustEcho`
72+
73+
### Session
74+
In the latest example we used `vim_command` method which is part of Neovim remote API.
75+
In the examples below we asume that we import `NVim.Session` in context of plugin.
76+
77+
### State
78+
Each plugin is [GenServer](http://elixir-lang.org/docs/stable/elixir/GenServer.html).
79+
So you can share the state between all actions while the plugin is running:
80+
```elixir
81+
on_event :cursor_moved do
82+
state = %{state | {move_counts: state[:move_counts] + 1}
83+
end
84+
85+
command :show_moves_count do
86+
moves_info = "Current moves: #{state[:move_count]}"
87+
vim_command("echo '#{moves_info}'")
88+
end
89+
```
90+
### Pre-evaluated values
91+
Any vim value can be pre-evaluated before the action will be triggered:
92+
```
93+
on_event :cursor_hold_i,
94+
pre_evaluate: %{
95+
"expand('cWORD')" => word_under_cursor
96+
}
97+
do
98+
if word_under_cursor == "Elixir" do
99+
something()
100+
end
101+
end
102+
```
103+
## Basic scripts
104+
This example demonstrates the highlighting of outdated packeges in your mix.exs
105+
```
106+
# ~/.config/nvim/rplugin/elixir/scripts/highlight_outdated_packages.exs

config/config.exs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
use Mix.Config
2+
3+
config :logger,
4+
backends: [{LoggerFileBackend, :error_log}],
5+
level: :error
6+
7+
config :logger, :error_log,
8+
path: Path.expand("#{__DIR__}/../tmp/neovim_elixir_host.log"),
9+
level: :error

installer/lib/install.ex

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
defmodule Mix.Tasks.Nvim.Install do
2+
use Mix.Task
3+
4+
import Mix.Generator
5+
@shortdoc "Installs the host to provided nvim config path."
6+
7+
def run(argv) do
8+
{_opts, argv} = OptionParser.parse!(argv)
9+
10+
case argv do
11+
[] ->
12+
Mix.raise "Expected NVIM_CONFIG_PATH to be given, please use \"mix nvim.install NVIM_CONFIG_PATH\""
13+
[nvim_config_path | _] ->
14+
create_file Path.join(nvim_config_path, "plugin/elixir_host.vim"), elixir_host_plugin_vim_text, force: true
15+
16+
remote_plugin_path = Path.join(nvim_config_path, "rplugin/elixir")
17+
18+
create_directory Path.join(remote_plugin_path, "scripts")
19+
create_directory Path.join(remote_plugin_path, "apps")
20+
create_file Path.join(remote_plugin_path, "mix.exs"), apps_mixfile_text, force: true
21+
create_file Path.join([remote_plugin_path, "config", "config.exs"]), apps_config_text, force: true
22+
23+
host_path = Path.join([remote_plugin_path, "apps","host"])
24+
25+
create_file Path.join(host_path, "mix.exs"), host_mixfile_text, force: true
26+
create_file Path.join([host_path, "config", "config.exs"]), host_config_text, force: true
27+
28+
print_successful_info
29+
end
30+
end
31+
32+
defp print_successful_info do
33+
Mix.shell.info [:green, """
34+
35+
Elixir host succesfully installed.
36+
"""]
37+
end
38+
39+
embed_text :host_mixfile, ~s"""
40+
defmodule Host.Mixfile do
41+
use Mix.Project
42+
43+
def project do
44+
[app: :host,
45+
version: "#{Mix.Project.config[:version]}",
46+
build_path: "../../_build",
47+
config_path: "../../config/config.exs",
48+
deps_path: "../../deps",
49+
lockfile: "../../mix.lock",
50+
elixir: "~> 1.3",
51+
deps: deps,
52+
aliases: aliases,
53+
escript: escript]
54+
end
55+
56+
def application do
57+
[applications: [:logger, :nvim], env: [plugin_module: NVim.Host.Plugin]]
58+
end
59+
60+
def escript do
61+
[main_module: NVim.Host, emu_args: "-noinput"]
62+
end
63+
64+
defp deps do
65+
[{:nvim, "#{Mix.Project.config[:version]}"}]
66+
end
67+
68+
defp aliases do
69+
["nvim.build_host": ["deps.get", "nvim.build_host"]]
70+
end
71+
end
72+
"""
73+
embed_text :apps_mixfile, """
74+
defmodule Elixir.Mixfile do
75+
use Mix.Project
76+
77+
def project do
78+
[apps_path: "apps",
79+
deps: []]
80+
end
81+
end
82+
"""
83+
84+
embed_text :apps_config, """
85+
use Mix.Config
86+
import_config "../apps/*/config/config.exs"
87+
"""
88+
89+
embed_text :host_config, ~S"""
90+
use Mix.Config
91+
92+
config :logger,
93+
backends: [{LoggerFileBackend, :error_log}],
94+
level: :error
95+
96+
config :logger, :error_log,
97+
path: Path.expand("#{__DIR__}/../neovim_elixir_host.log"),
98+
level: :error
99+
"""
100+
101+
embed_text :elixir_host_plugin_vim, """
102+
let s:nvim_path = expand('<sfile>:p:h:h')
103+
let s:xdg_home_path = expand('<sfile>:p:h:h:h')
104+
105+
function! s:RequireElixirHost(host)
106+
try
107+
let channel_id = rpcstart(s:nvim_path . '/rplugin/elixir/apps/host/host',[])
108+
if rpcrequest(channel_id, 'poll') == 'ok'
109+
return channel_id
110+
endif
111+
catch
112+
endtry
113+
throw 'Failed to load elixir host.' . expand('<sfile>') .
114+
\ ' More information can be found in elixir host log file.'
115+
endfunction
116+
117+
call remote#host#Register('elixir', '{scripts/*.exs,apps/*}', function('s:RequireElixirHost'))
118+
119+
function! UpdateElixirPlugins()
120+
execute '!cd ' . s:nvim_path . '/rplugin/elixir/apps/host && MIX_ENV=prod mix do deps.get, nvim.build_host --xdg-home-path ' . s:xdg_home_path . ' --vim-rc-path ' . s:nvim_path . '/init.vim'
121+
endfunction
122+
command! UpdateElixirPlugins call UpdateElixirPlugins()
123+
"""
124+
end

installer/lib/remove.ex

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
defmodule Mix.Tasks.Nvim.Remove do
2+
use Mix.Task
3+
4+
@shortdoc "Removes the host(include ALL installed plugins) for provided nvim config path."
5+
6+
def run(argv) do
7+
{_opts, argv} = OptionParser.parse!(argv)
8+
9+
case argv do
10+
[] ->
11+
Mix.raise "Expected NVIM_CONFIG_PATH to be given, please use \"mix nvim.remove NVIM_CONFIG_PATH\""
12+
[nvim_config_path | _] ->
13+
File.rm Path.join(nvim_config_path, "plugin/elixir_host.vim")
14+
15+
File.rm Path.join(nvim_config_path, "rplugin/elixir/mix.exs")
16+
safely_remove_directory(Path.join(nvim_config_path, "rplugin/elixir/scripts"))
17+
18+
File.rm_rf Path.join(nvim_config_path, "rplugin/elixir/_build")
19+
File.rm_rf Path.join(nvim_config_path, "rplugin/elixir/config")
20+
File.rm_rf Path.join(nvim_config_path, "rplugin/elixir/apps/host")
21+
safely_remove_directory(Path.join(nvim_config_path, "rplugin/elixir/apps"))
22+
23+
Mix.shell.info "Elixir host succesfully removed."
24+
end
25+
end
26+
27+
defp safely_remove_directory(path) do
28+
if directory_empty?(path) do
29+
File.rm_rf(path)
30+
else
31+
Mix.shell.info "#{path} is not removed because is not empty."
32+
end
33+
end
34+
35+
defp directory_empty?(path) do
36+
Path.join(path, "*") |> Path.wildcard |> Enum.empty?
37+
end
38+
end

installer/mix.exs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
defmodule NVim.Installer.Mixfile do
2+
use Mix.Project
3+
4+
def project do
5+
[app: :nvim_installer,
6+
version: "0.1.0",
7+
elixir: "~> 1.3"]
8+
end
9+
10+
def application do
11+
[applications: []]
12+
end
13+
end

installer/test/install_test.exs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
defmodule Mix.Tasks.Nvim.InstallTest do
2+
use ExUnit.Case
3+
4+
test "installs the host to a provided directory" do
5+
in_tmp_dir("nvim_config", fn(path)->
6+
output = ExUnit.CaptureIO.capture_io(fn-> Mix.Tasks.Nvim.Install.run [path] end)
7+
8+
assert_file_exists "#{path}/plugin/elixir_host.vim"
9+
assert_directory_exists "#{path}/rplugin/elixir/scripts"
10+
assert_directory_exists "#{path}/rplugin/elixir/apps/host"
11+
assert_file_exists "#{path}/rplugin/elixir/config/config.exs"
12+
assert_file_exists "#{path}/rplugin/elixir/mix.exs"
13+
14+
assert output =~ "Elixir host succesfully installed."
15+
end)
16+
end
17+
18+
defp in_tmp_dir(path, callback) do
19+
expanded_path = Path.expand("../../tmp/#{path}", __DIR__)
20+
File.rm_rf! expanded_path
21+
File.mkdir! expanded_path
22+
23+
callback.(expanded_path)
24+
end
25+
26+
defp assert_file_exists(path) do
27+
assert File.exists?(path)
28+
end
29+
30+
defp assert_directory_exists(path) do
31+
assert File.dir?(path)
32+
end
33+
end

installer/test/test_helper.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ExUnit.start

lib/host/handler.ex

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
defmodule NVim.Host.Handler do
2+
require Logger
3+
alias NVim.PluginManager
4+
5+
def on_call(_session, "poll", _params) do
6+
{:ok, "ok"}
7+
end
8+
9+
def on_call(_session, "specs", [plugin_path]) do
10+
try do
11+
{:ok, plugin} = PluginManager.lookup(plugin_path)
12+
{:ok, plugin.specs}
13+
rescue
14+
any ->
15+
Logger.error("Plugin path: #{plugin_path}, error: #{inspect any}")
16+
{:error, "Troubles with load a plugin. See elixir host log for more information"}
17+
end
18+
end
19+
20+
def on_call(session, method, params) do
21+
try do
22+
on_action(session, method, params, sync: true)
23+
catch
24+
any ->
25+
Logger.error("cathc error: #{inspect any}")
26+
rescue
27+
error ->
28+
Logger.error("call error: #{inspect error}")
29+
{:error, "Error: #{inspect error}"}
30+
end
31+
end
32+
33+
def on_notify(session, method, params) do
34+
on_action(session, method, params, sync: false)
35+
end
36+
37+
defp on_action(_session, method, params, sync: sync) do
38+
[plugin_path, action_type, action_name | rest] = String.split(method, ":")
39+
action_name = if rest != [], do: "#{action_name}:#{hd(rest)}", else: action_name
40+
41+
case PluginManager.lookup(plugin_path) do
42+
{:ok, plugin} ->
43+
response = plugin.handle_rpc_method(action_type, action_name, params)
44+
if sync, do: handle_plugin_response(response)
45+
_ ->
46+
Logger.error("Plugin #{plugin_path} was not loaded")
47+
{:error, "Problem with loading plugin for: #{method}"}
48+
end
49+
end
50+
51+
defp handle_plugin_response({status, _value}= response) when status in [:ok, :error], do: response
52+
defp handle_plugin_response(_), do: {:error, "Problem with handle action by plugin"}
53+
end

0 commit comments

Comments
 (0)