Set up a HammerSpoon URL event to open a file in neovim. Can be used with phoenix-live-reload to jump to the definition (or caller) of a phoenix live-view component.
First, install HammerSpoon.
Then, clone this repository to ~/.hammerspoon/Spoons/OpenInNeovim.spoon
, like
so...
git clone https://github.com/JuneKelly/OpenInNeovim.spoon ~/.hammerspoon/Spoons/OpenInNeovim.spoon
In hammerspoon code (either in the console, or in ~/.hammerspoon/init.lua
),
this spoon can be loaded like so:
openInNeovim = hs.loadSpoon("OpenInNeovim")
To bind a URL event handler, call openInNeovim.bind
, with a table of
configuration options.
-
token
: (required, minimum length 12 characters) the URL must include this token as a query parametertoken
. If the URL does not contain this parameter, or it does not match, then the error will be shown in a notification.- Why is this required? As much as we try to defend against security issues,
in some sense this feature is fundamentally built around telling
nvim
to execute some code, so it is best to guard the entry-point so it can only be activated by URLs having this secret token.
- Why is this required? As much as we try to defend against security issues,
in some sense this feature is fundamentally built around telling
-
nvimPath
: (required) full path to thenvim
executable- (this can be found easily by running
command -v nvim
in your shell)
- (this can be found easily by running
-
nvimServerPipePath
: (required) full path to thenvim
server pipe file- (this is the file path you provided when starting
nvim
with the--listen
flag)
- (this is the file path you provided when starting
-
eventName
: (optional, default"openInNeovim"
) name of the hammerspoon event, which in practice means the part of the URL that comes afterhammerspoon://
. -
foregroundApp
: (optional, defaultnil
) if present, bring this app to the foreground after the file has been opened. Must be the name of a MacOS app, like"iTerm2"
orGhostty
-
translateRootPath
: (optional, defaultnil
) a table with two fields:from
, andto
. If non-nil, the file path is altered to replace the segment matchingfrom
at the start, with to stringto
. Useful if your phoenix server runs in a docker environment where it's filesystem is different from the host where yournvim
editor is running -
skipValidateFileExists
: (optional, defaultfalse
) By default, we check that the target file path is a real path, and exists, before sending the command tonvim
. This helps defend against remote code execution attacks. If this option is set totrue
, the validation is skipped. This could be useful if thenvim
instance is operating on a different filesystem from hammerspoon.
Here's an example using all of the configuration options:
openInNeovim = hs.loadSpoon("OpenInNeovim")
openInNeovim.bind({
nvimPath = "/opt/homebrew/bin/nvim",
nvimServerPipePath = "/Users/somebody/.cache/nvim/server.pipe",
token = "a_dreadful_secret",
foregroundApp = "iTerm2",
eventName = "aNiceCustomEventName",
translateRootPath = {
from = "/app/inside/docker/",
to = "/Users/somebody/projects/cool-web-app/"
}
})
This event handler is triggered by opening a URL that looks like:
hammerspoon://openInNeovim?file=<File Path>&line=<Line Number>
The following query parameters are supported:
file
: (required) path to the file to open (URL encoded)line
: (required) line number to opentoken
: (optional) secret token to check againstconfig.token
As a prerequisite, we need to find the full path to the nvim
executable:
command -v nvim
If you've installed neovim via homebrew, then the result is probably something like /opt/homebrew/bin/nvim
.
In this example, we have one instance of nvim
, acting as a server. We then
configure OpenInNeovim to open files in this single nvim
server. If you tend
to work on one project at a time, this should be sufficient.
To start nvim
in server mode, we pass the --listen <path>
parameter, where
<path>
is a path to a pipe file, which neovim will create:
nvim --listen ~/.cache/nvim/server.pipe
You can make this easier to do repeatedly by creating an alias. For example, in
zsh
:
# add this to .zshrc
alias nvim-server 'nvim --listen ~/.cache/nvim/server.pipe'
...or in fish
:
# run this once in fish shell
alias --save nvim-server='nvim --listen ~/.cache/nvim/server.pipe'
We need a secret token, to secure this URL endpoint. An easy way to do this is
by running uuidgen
in the shell:
uuidgen
# => 07048977-9...
Add the following to ~/.hammerspoon/init.lua
:
openInNeovim = hs.loadSpoon("OpenInNeovim")
openInNeovim.bind({
nvimPath = "<full path to nvim executable>",
nvimServerPipePath = "<full path to nvim server pipe>",
token = "<random token string>",
})
Quit and re-open Hammerspoon. Look in the Hammerspoon console, and you should see log lines indicating that OpenInNeovim has been loaded, and a URL handler has been bound:
2024-12-26 14:26:31: -- Loading Spoon: OpenInNeovim
2024-12-26 14:26:31: [OpenInNeovim] Bind {
...
}
2024-12-26 14:26:31: [OpenInNeovim] Binding to URL 'openInNeovim'
See the "Jumping to HEEX Function
Definitions"
section of the phoenix_live_reload
README file.
PLUG_EDITOR = 'hammerspoon://openInNeovim?token=<TOKEN>&file=__FILE__&line=__LINE__'
Now, when you hold d
and click a phoenix live-view component in the browser,
it should open the component definition in neovim, and show a notification to
that effect. If not, check the hammerspoon logs.
The previous example doesn't work so well if we tend to keep multiple instances of neovim open at a given time, like if we work on several projects at once. In this case, we want to start multiple neovim servers, one for each project, and configure multiple instances of OpenInNeovim, each pointing to the relevant neovim server.
Let's imagine that we regularly work on two phoenix projects: statler
and waldorf
. Both of these projects live in our ~/code
directory:
/Users/somebody/code/statler
/Users/somebody/code/waldorf
First, we need a way to start an nvim
server with a pipe file at a predictable location on the file-system. We can solve this problem by taking the following steps:
- Take the current working directory from
pwd
, as a string - Change all
/
characters to_
, to form a "slug" representation of the working directory - Use this slug to name a pipe file in the user's temp directory (
$TMPDIR
)
For example, in zsh
:
echo "${TMPDIR}nvim-server$(pwd | sed 's/\//_/g').pipe"
This will produce a string like:
/var/folders/vr/c_awj73s1264r2bz1f72y99m1241zl/T/nvim-server_Users_somebody_code_waldorf.pipe
If we create our pipe file in this way, we can identify separate neovim servers in separate project directories:
# in zsh
nvim --listen "${TMPDIR}nvim-server$(pwd | sed 's/\//_/g').pipe"
# in fish
nvim --listen (string join '' $TMPDIR "nvim-server" (pwd | sed 's/\\//_/g') ".pipe")
To make this invocation easier, we can create a shell function.
If you're using zsh
(the default shell on recent versions of macOS), add the following to ~/.zshrc
:
function nvim-server() {
nvim --listen "${TMPDIR}nvim-server$(pwd | sed 's/\//_/g').pipe" "$@"
}
Or, if you use fish
, add the following to ~/.config/fish/functions/nvim-server.fish
:
function nvim-server --wraps='nvim' --description 'Start nvim server with pipe file based on PWD'
nvim --listen (string join '' $TMPDIR "nvim-server" (pwd | sed 's/\\//_/g') ".pipe") $argv
end
Open a new shell, run nvim-server
, and look in $TMPDIR
(by running ls -la $TMPDIR
). You should see a file ending in .pipe
, with a name based on the directory in which you ran nvim-server
.
For example, if we were to navigate to each of our demo projects (statler
and waldorf
), and run nvim-server
in each of them, when we run ls -la $TMPDIR
we should see two files like:
... nvim-server_Users_somebody_code_statler.pipe
... nvim-server_Users_somebody_code_waldorf.pipe
We need one secret token for each project, to secure each URL endpoint. An easy way to do this is
by running uuidgen
in the shell multiple times:
uuidgen
# => 07048977-9...
uuidgen
# => AC7EA84C-6...
Let's configure OpenInNeovim for each of our projects, statler
and waldorf
.
Add the following to ~/.hammerspoon/init.lua
, replacing the placeholder
strings with your own values:
openInNeovim = hs.loadSpoon("OpenInNeovim")
local function pipePath(path)
return os.getenv("TMPDIR") .. "nvim-server" .. path:gsub("/", "_") .. ".pipe"
end
local nvimPath = "<full path to nvim executable>"
openInNeovim.bind({
eventName = "openInNeovim_statler",
nvimPath = nvimPath,
nvimServerPipePath = pipePath("/Users/somebody/code/statler"),
token = "<a random string>",
foregroundApp = "iTerm2",
})
openInNeovim.bind({
eventName = "openInNeovim_waldorf",
nvimPath = nvimPath,
nvimServerPipePath = pipePath("/Users/somebody/code/waldorf"),
token = "<a different random string>",
foregroundApp = "iTerm2",
})
Finally, in each of our projects, we can set PLUG_EDITOR
:
In the statler
project:
PLUG_EDITOR = 'hammerspoon://openInNeovim_statler?token=<TOKEN>&file=__FILE__&line=__LINE__'
And in the waldorf
project:
PLUG_EDITOR = 'hammerspoon://openInNeovim_waldorf?token=<TOKEN>&file=__FILE__&line=__LINE__'
(See the "Jumping to HEEX Function
Definitions"
section of the phoenix_live_reload
README file.)
This software is published under the MIT license:
Copyright 2025 June Kelly
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.