Software Engineer, wanna be hacker.

My journey on building nvim:// URL handler for clickable stacktraces

I was scrolling through the Phoenix documentation and stumbled upon one of its hidden gems: the PLUG_EDITOR environment variable. What? When configured, it allows you to open files directly from Plug.Debugger’s error pages in your favorite text editor.


It works beautifully out of the box with VSCode and TextMate:

export PLUG_EDITOR="vscode://file/__FILE__:__LINE__"

# or

export PLUG_EDITOR="txmt://open?url=file://__FILE__&line=__LINE__"

But I live in the terminal, my editor of choice is Neovim nowadays and I felt left out. There’s no built-in nvim:// URL scheme, yet I really wanted the same smooth experience in Neovim, to enjoy this one-click debugging experience: no more copying and pasting filenames… Just let me click! 🐭


Guess what, in this post, I’ll show you how to build a custom nvim:// URL handler that brings clickable stacktraces to Neovim. We’ll start simple and evolve it into an over-engineered masterpiece based on my workflow.


⚠️ WARNING: This is only for macOS, sorry :/


A simple URL handler for Neovim

Before doing anything crazy, I decided to start with the basics: how to create a URL handler that reads the URL, extracts the filename and line number, starts my terminal and opens the file in a new instance of Neovim. That’s already something, right?


First, I turned to ChatGPT and Google searches, and I immediately realized that wasn’t as straightforward as I’d hoped. My first attempt was a complete failure—I tried creating a macOS app using Automator, spent a couple of hours on it, but nothing worked. Eventually, I gave up.

The day after, I asked Claude and the LLM suggested me to write an AppleScript for registering the custom URL schema and parsing the URL, then shelling out to a Bash script for doing the rest. Yay!


This approach had its own quirks too—sometimes the custom URL schema wasn’t properly registered and I didn’t know how to observe/debug it, apart from seeing my nvim://... URL being ignored by the handler and opened as a Google search 😅. But eventually I made it work!


Custom URL format

Easy. I kept it consistent with the format used by VSCode, but with the nvim prefix: nvim://file/__FILE__:__LINE__.


Next, AppleScript

Now that we’ve defined the URL format, we need to write an AppleScript that:

  • registers the custom URL handler
  • parses the URL and calls a bash script with the result

I was not familiar with AppleScript, I therefore resorted to Claude again to help me write the script.


You can create a new one by opening the Script Editor application on your Mac, then copy and paste the following code with the right adjustments based on your setup:

on open location schemeUrl
   -- Save original delimiters
  set oldDelims to AppleScript's text item delimiters

  try
    -- Validate and extract the file path from the URL
    -- Split the URL on "nvim://file/" to separate protocol from path
    set AppleScript's text item delimiters to {"nvim://file/"}

     -- Check if we have at least 2 parts (before and after the delimiter)
    if (count of text items of schemeUrl) < 2 then error "Invalid URL format"

    -- Extract everything after "nvim://file/" (this is our file path with line number)
    -- For "nvim://file/path/to/file.txt:42", this gives us "path/to/file.txt:42"
    set filePath to item 2 of the text items of schemeUrl

    -- Pass file path to the bash script
    do shell script "/bin/bash " & quoted form of "/PATH/TO/YOUR/nvim_url_handler.sh" & " " & quoted form of filePath

  on error errMsg
    display alert "Error processing URL: " & errMsg
  end try

  -- Restore original delimiters
  set AppleScript's text item delimiters to oldDelims
end open location

Then, save it as application in the Applications folder, for example as Nvim URL Handler.app:

Save AppleScript as Application

And for registering the URL Schema application you need to update the Info.plist file inside the newly created app:

  • Right click on the application you just created
  • Select “Show Package Contents”
  • Open the Info.plist file
  • Find the lines
<key>CFBundleName</key>
<string>...</string>

And append after them:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>Nvim Scheme</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>nvim</string>
    </array>
  </dict>
</array>

👆This registers the nvim:// URL schema to your application… Don’t forget to save the file.


Bash script to open the file in Nvim

You might have noticed that the AppleScript calls a bash script with the extracted file path. This script is responsible for opening the file in Neovim.

#!/bin/bash

FILE_PATH="$1"

if [ -n "$FILE_PATH" ]; then
    # Open a new instance of alacritty and run nvim with the provided file path
    /opt/homebrew/bin/alacritty -e /opt/homebrew/bin/nvim "$FILE_PATH"
fi

I’m using the wonderful Alacritty, but you can replace it with your favorite terminal emulator. Then, ensure the nvim path is correct too (I installed it with brew).

Last touch, make it executable chmod +x /path/to/your/script.sh and double check that the script path is the same as the one specified in the Apple Script above.


If all the stars are aligned ✨, you should be able to open a file in Neovim by clicking on a link like nvim://file/path/to/file.txt:42 in your browser 🤞.


The over-engineered masterpiece

Nice, we are now able to open a file in nvim by clicking on a link 🤩

But I found spawning a new shell not so convenient, I wanted the file to appear in my current terminal and instance of Neovim, so I started to think about how to achieve that based on my workflow and setup, by taking advantage of Neovim special features, like RPC.


🥁🥁🥁 And I ended up with the following solution:

  1. Start Neovim with socket listening on a specific path based on the current tmux session name
  2. Update the Apple Script to handle nvim:// ... URL with tmux session name as additional query param
  3. Update the bash script to open the file on the running Nvim instance in the specific tmux session

The over-engineering is actually thoughtful engineering - you’ve created a robust tool that handles the complexity of multiple tmux sessions and nvim instances gracefully. (Claude)

1. Start Neovim server with socket address based on tmux session

I believe I once watched this video by @teej on nvim --remote and how to use it, it stuck with me and I only needed a reason to use it.

Neovim starts a MessagePack-RPC server at startup, creating a socket with a random address by default. The --listen option lets you specify a custom socket path that other processes can connect to.


I’m a tmux user, and I have an alias to start a tmux session named as the folder I’m in, which most of the time is the name of the Git repository. I therefore opted to re-use this convention and name the socket path after the folder name.

I created a pair of bash function and alias to accomplish that, an excerpt:

nvim_listen() {
  # Get current tmux session name
  TMUX_SESSION_NAME=$(tmux display-message -p '#S')

  # Set socket path based on session name
  SOCKET_PATH="/tmp/nvim-$TMUX_SESSION_NAME"

  echo "Nvim will listen on socket: $SOCKET_PATH"

  # Start Neovim with the socket
  command nvim --listen "$SOCKET_PATH" "$@"
}

# Start nvim listening to a socket if in tmux session
nvim_with_socket() {
    if [ -n "$TMUX" ]; then
        echo "Using tmux session-aware Neovim"
        nvim_listen "$@"
    else
        command nvim "$@"
    fi
}

# Open nvim in the current folder with socket listening
alias nn="nvim_with_socket ."

To recap, typing nn in a tmux session will open the current directory in Neovim and configure it to listen on a socket named after the current tmux session.


2. Update the Apple Script to parse the tmux-session param

Next, I needed to update the AppleScript to handle the new tmux-session query param in the URL. The updated script looks like this:

on open location schemeUrl
  -- Save original delimiters
  set oldDelims to AppleScript's text item delimiters

  try
    -- Extract everything after nvim://
    set AppleScript's text item delimiters to {"nvim://"}
    if (count of text items of schemeUrl) < 2 then error "Invalid URL format"
    set fullContent to item 2 of the text items of schemeUrl

    -- Extract the file path (everything between file/ and ?)
    set AppleScript's text item delimiters to {"file/"}
    if (count of text items of fullContent) < 2 then error "Invalid URL format, missing file path"
    set pathWithQuery to item 2 of the text items of fullContent

    -- Split path and query
    set AppleScript's text item delimiters to {"?"}
    set filePath to item 1 of the text items of pathWithQuery

    -- Extract tmux session from query if present
    set tmuxSession to ""
    if (count of text items of pathWithQuery) > 1 then
      set queryPart to item 2 of the text items of pathWithQuery

      -- Look for tmux-session parameter
      set AppleScript's text item delimiters to {"tmux-session="}
      if (count of text items of queryPart) > 1 then
        set tmuxSession to item 2 of the text items of queryPart

        -- Handle additional query parameters (if any)
        set AppleScript's text item delimiters to {"&"}
        set tmuxSession to item 1 of the text items of tmuxSession
      end if
    end if

    -- Pass tmux session name and file path to the bash script
    do shell script "/bin/bash " & quoted form of "/PATH/TO/SCRIPT.sh" & " " & quoted form of tmuxSession & " " & quoted form of filePath

  on error errMsg
    display alert "Error processing URL: " & errMsg
  end try

  -- Restore original delimiters
  set AppleScript's text item delimiters to oldDelims
end open location


3. Update the bash script to open the file on the running Neovim instance

And finally, I updated the bash script to handle the tmux session name and open the file in the running Nvim instance:

#!/bin/bash
# URL Example: nvim://file//Users/nico/dotfiles/README.md:10?tmux-session=dotfiles

# Extract socket and path from arguments
TMUX_SESSION_NAME="$1"
FILE_PATH="$2"

# Check if both arguments were provided
if [ -n "$TMUX_SESSION_NAME" ] && [ -n "$FILE_PATH" ]; then
    # Build the socket path (see /bin/nvim_listen script)
    SOCKET_PATH="/tmp/nvim-$TMUX_SESSION_NAME"

    # Check if the socket exists
    if [ -e "$SOCKET_PATH" ]; then
        # Try connecting to existing nvim instance
        /opt/homebrew/bin/nvim --server "$SOCKET_PATH" --remote "$FILE_PATH"

        if [ $? -ne 0 ]; then
            # Fall back to opening in a new instance if remote connection fails
            /opt/homebrew/bin/alacritty -e /opt/homebrew/bin/nvim "$FILE_PATH"
        fi
    else
        # Socket doesn't exist, fall back to opening in a new window
        /opt/homebrew/bin/alacritty -e /opt/homebrew/bin/nvim "$FILE_PATH"
    fi
elif [ -n "$FILE_PATH" ]; then
    # Only file path provided (no socket), open in new Alacritty window
    /opt/homebrew/bin/alacritty -e /opt/homebrew/bin/nvim "$FILE_PATH"
fi

And voilà! Now I can click on a link like nvim://file/path/to/file.txt:42?tmux-session=dotfiles in my browser and it will open the file in the current Neovim instance running in the tmux session named dotfiles. Over-engineered? Maybe, but it works like a charm and I love it!


Ah I almost forgot why we are here! Now I can use the PLUG_EDITOR environment variable to use the nvim:// custom URL schema in my Phoenix application:

PLUG_EDITOR = "nvim://file/__FILE__:__LINE__?tmux-session=name-of-the-folder"

And here’s the final result in action:

Clickable stacktraces opening in Neovim


One Final Unnecessary Optimization: Dynamic Session Names with Mise

If you scroll up, you can see that the tmux session name in our PLUG_EDITOR environment variable is hardcoded. What a shame, we can do better!

I’m using Mise as tool version manager and more, and with that you can also handle environment variables dynamically scoped to a specific folder 🤩.


With Mise, we can automatically set the PLUG_EDITOR variable with the current tmux session name. Here’s how I set it up in my Elixir projects:


First, create a .mise.toml file in your project root:

[env]
# Source a script to set dynamic environment variables
_.source = "~/dotfiles/bin/mise-env.sh"

PLUG_EDITOR = "nvim://file/__FILE__:__LINE__?tmux-session={{env.TMUX_SESSION_NAME}}"


Then, create the mise-env.sh script that dynamically extracts the tmux session name and sets the corresponding TMUX_SESSION_NAME env variable.

#!/usr/bin/env bash

if [ -n "$TMUX" ]; then
    export TMUX_SESSION_NAME=$(tmux display-message -p '#S')
else
    export TMUX_SESSION_NAME=""
fi


Now, whenever you cd into your project directory (assuming you have Mise installed and configured), it will:

  1. Detect you’re in a tmux session
  2. Extract the current tmux session name
  3. Set PLUG_EDITOR with the correct tmux session name automatically as query param

No more hardcoding session names! The URL handler will always open files in the correct Neovim instance for your current tmux session. :chef-kiss:


Complete Implementation

If you want to use this setup, you can find my full implementation in my dotfiles:

The code includes additional features like logging, error handling, and has been battle-tested in my daily workflow for a few months now. Feel free to adapt it to your own setup!


Hope my journey will spare you some headaches and help you enjoy clickable stacktraces in Neovim as much as I do! If you have any questions or suggestions, feel free to reach out.


Happy hacking! 🚀 :wq