REPLConnect.jl

TLWR: I have a Neovim plugin and small Julia package that connects a Julia REPL to a Neovim session, allowing for a variable explorer pane and viewer, as well as a plot pane that works over SSH, and object autocompletion based on objects that are loaded in the REPL.

Small announcement of a Neovim plugin and associated Julia package I’ve been playing around with for the last couple of weeks to help aid my Julia development of data-heavy analysis. There are a couple of things that I’ve missed about IDE-based development since switching to Neovim about 3 years, and most have been resolved with improvements to the language server(s) and testing environment. However, the main pain point that stuck around (apart from debugging) was the RStudio/Positron-style variable viewer and plotting pane. When Posit announced Ark kernel and lsp a few years ago I was interested to see if it would ever be extended to work in Neovim, and about a year or so of work Jacob released jet.nvim that provides an interface to do this through language-specific extension (e.g., jet.ark). I got to playing around with an implementation for the IJulia kernel, that you can see at jet.julia. It works and provides some useful features like the object autocompletion and variable pane/viewer. But I much prefer working in the actual Julia REPL over the IJulia kernel, so I wondered if we could achieve a similar outcome using Unix sockets to pass information to and from the REPL.

REPLConnect.nvim and REPLConnect.jl are the outcome of this experimentation (with a lot of the JSON-RPC approached based on the work by positron-julia).

The general idea is that the Neovim plugin handles all the core functionality, and each language has it’s own Neovim plugin to hook into the core one, and a language-specific package to handle things from the REPL’s end of things. I have added a small function call to my startup.jl file that activates it for each interactive REPL session and does things like track REPL entry history (only the last 10 by default) so you can detach and reconnect to a different running REPL in the situation where you have a couple running in parallel, or “capture” plots so they are sent to the internal plot viewer that opens in Neovim and can work over SSH (this hooks into snacks.nvim and assumes you have a terminal that uses the Kitty image protocol).

# in my startup.jl
if isinteractive()
    # you likely want to disable VimBindings for a nvim
    # terminal if you use VimBindings
    if !haskey(ENV, "NVIM")
        using VimBindings
    end

    using REPLConnect: REPLConnect
    REPLConnect.start()
    REPLConnect.capture_plots!(true)
    REPLConnect.record_history!(true)
end

If you prefer to use REPLs created outside of Neovim and send code using something like vim-slime, that’s no problem, but you can also create Neovim splits and terminals and connect to those if you prefer. The choice is yours. The thing about this plugin that I’m (probably unreasonably) pleased with is that it’s possible to define default layouts in your neovim config so the various panes that open/close as you need them do so in a pre-specified way, and you can set them differently for external REPLs vs vertical nvim terminals vs horizontal nvim terminals. And, there’s even a function to “capture” the current layout allowing you to manually adjust the sizing to your preferences and then bake it into your Neovim config!

Show my neovim plugin config
return {
  {
    'arnold-c/REPLConnect.jl',
    dependencies = { 'arnold-c/REPLConnect.nvim' },
    main = 'replconnect.julia',
    opts = { command = { 'julia', '--project=.' } },
  },
  {
    'arnold-c/REPLConnect.R',
    dependencies = { 'arnold-c/REPLConnect.nvim' },
    main = 'replconnect.r',
    opts = { command = { 'arf' } },
  },
  {
    'arnold-c/REPLConnect.nvim',
    main = 'replconnect',
    ft = { 'r', 'julia' },

    -- The :REPL* commands are filetype specific, so no per-language routing is needed.
    -- NOTE: only :REPLStart is confirmed; verify the other command names.
    keys = {
      { '<leader>ra', '<cmd>REPLAttach<cr>', desc = 'REPL: attach to external REPL' },
      { '<leader>rd', '<cmd>REPLDetach<cr>', desc = 'REPL: detach from REPL' },
      { '<leader>rs', '<cmd>REPLStart<cr>', desc = 'REPL: start REPL with default split' },
      { '<leader>rS', '<cmd>REPLStop<cr>', desc = 'REPL: stop REPL(s) within the project directory' },
      { '<leader>rl', '<cmd>REPLLayout<cr>', desc = 'REPL: save current layout to clipboard' },
      { '<leader>rp', '<cmd>REPLPlots<cr>', desc = 'REPL: toggle plots pane' },
      { '<leader>rv', '<cmd>REPLVariables<cr>', desc = 'REPL: toggle variables pane' },
    },

    opts = {
      -- exclude items from appearing in the variable pane e.g., default templates loaded in your startup.jl
      exclude = {
        'default_dir',
        'default_templates',
        'default_user',
        'home_dir',
        'in_global_env',
        'installed_deps',
        'inplace_template',
        'internal_template_dir',
        'optional_dependencies',
        'personal_templates',
        'required_dependencies',
        'specified_activate_project',
      },
      completion = true, -- runtime completion LSP in buffers of attached REPLs' languages
      poll_interval_ms = 1500, -- refresh the visible variables pane; 0 = off
      update_while_busy = false, -- fetch changes while the REPL evaluates
      repl = { -- :REPLStart; these apply to every language
        split = 'vertical', -- or "horizontal"
        enter = true, -- start in terminal mode
        capture_plots = true,
        plot_limit = 50,
      },
      layout = {
        -- One layout string per way the REPL is shown; see "Pane layouts" below.
        horizontal = [[ editor / repl @ 36% | ((plots \ view) @ 60% / vars) @ 35% ]],
        vertical = [[ repl @ 22% | editor | ((plots \ view) @ 62% / vars) @ 35% ]],
        external = [[ editor | ((plots \ view) @ 56% / vars) @ 40% ]],
      },
      plots = {
        renderer = 'auto', -- "snacks" | "image.nvim" | "external" | custom table
        remote = 'auto', -- stream images via Snacks: "auto" (over SSH, if snacks is loaded) | true | false
        auto_open = true,
        keymaps = {
          close = 'q',
          next = '<C-n>',
          previous = '<C-p>',
          refresh = 'r',
          follow = 'f',
          external = 'o',
          save = 's',
        },
      },
      viewer = {
        keymaps = {
          close = 'q',
          refresh = 'r',
          next_page = ']p',
          previous_page = '[p',
          scroll_right = '<Tab>',
          scroll_left = '<S-Tab>',
          next_slice = { '<C-n>', ']s' },
          previous_slice = { '<C-p>', '[s' },
          select_slice = '<LeftMouse>',
        },
      },
    },
    config = function(_, opts)
      require('replconnect').setup(opts)

      -- On the first Julia/R file of a session, offer to attach to running REPLs of
      -- that language. A lone session in the cwd is attached automatically;
      -- anything else (several, or none in the cwd) brings up the picker.
      local discover = require 'replconnect.discover'
      local languages = { julia = 'julia', r = 'r' } -- filetype -> REPLConnect language
      local prompted = {}

      local function connect(filetype)
        local language = languages[filetype]
        if not language or prompted[filetype] then
          return
        end
        prompted[filetype] = true

        local cwd = vim.fn.getcwd()
        local sessions = vim.tbl_filter(function(info)
          return discover.language(info) == language
        end, discover.list { cwd = cwd })
        if #sessions == 0 then
          return
        end

        local here = vim.tbl_filter(function(info)
          return info.cwd == cwd
        end, sessions)
        vim.cmd('REPLAttach' .. (#here == 1 and (' ' .. here[1].pid) or ''))
      end

      vim.api.nvim_create_autocmd('FileType', {
        group = vim.api.nvim_create_augroup('replconnect.connect-first-file', { clear = true }),
        pattern = vim.tbl_keys(languages),
        callback = function(args)
          vim.schedule(function()
            connect(vim.bo[args.buf].filetype)
          end)
        end,
      })

      -- Lazy-loaded on a filetype: the triggering buffer's FileType already fired.
      connect(vim.bo.filetype)
    end,
  },
}
My `blink.cmp` sources options to prefer showing REPLConnect.nvim suggestions over other enabled LSPs
    sources = {
      default = {
        'lsp',
        'path',
        'snippets',
        'buffer',
      },
      providers = {
        lsp = {
          -- Boost items from Jet and REPLConnect clients above other LSP clients.
          transform_items = function(_, items)
            for _, item in ipairs(items) do
              local client = vim.lsp.get_client_by_id(item.client_id)
              if client and (client.name:match '^replconnect') then
                item.score_offset = (item.score_offset or 0) + 100
              end
            end
            return items
          end,
        },
      },
    },
  },
  opts_extend = { 'sources.default' },

One other neat feature is the variable viewer (that can be accessed by pressing v on a variable in the general variable pane) not only works for dataframes and other Tables.jl objects, but also for 3D arrays and for function methods. This last one allows you to quickly view all the method definitions for a function, which is pretty handy with generics in R and multiple dispatch in Julia, and also comes with a go-to-definition gd keymap that brings you to the file location where the function is defined (using a snacks picker to choose the correct method). It is set up to use the language server, but right now seems to always trigger the fallback method that searches the projects’ files manually. Either way, it seems to be working out OK in my day-to-day usage, but I’m working to figure out whether the issue is on my end (likely) or in how capabilities are advertised by the fatou lsp (unlikely).

It’s definitely an early-beta type set of packages and will probably experience some (maybe breaking) changes over the coming months, but I’ve been daily driving it with Julia and R (via the arf REPL) for the last 1.5-2 weeks and it’s been pretty reliable.

And final obligatory disclosure: yes, I did use AI to help write the bulk of this and iterate on a number of different architectures. I think the code is pretty reasonable as I’ve been going through and reviewing it along each step of the process, but you may prefer not to use it because of AI - that’s fine! I just wanted to share in case others find it useful.

I’ve been a long-time user of REPLSmuggler, and definitely prefer to have Julia separate from nvim itself, I do stuff that may cause Julia to crash or where Julia has to run in a remote server. Might try this out this weekend!