stevearc / aerial.nvim

Neovim plugin for a code outline window
MIT License
1.66k stars 82 forks source link

[feat] Neotest integration, make aerial Neotest's summary window #263

Open fnune opened 1 year ago

fnune commented 1 year ago

Neotest provides a summary window feature that shows tests inside a buffer and allows users to interact with them.

image Copied from the Neotest README

As a user of aerial, I want the functionality of Neotest's summary window to be available inside the aerial window. Namely:

Toggle to see all features of Neotest's summary window
    summary = {
      animated = true,
      enabled = true,
      expand_errors = true,
      follow = true,
      mappings = {
        attach = "a",
        clear_marked = "M",
        clear_target = "T",
        debug = "d",
        debug_marked = "D",
        expand = { "", "<2-LeftMouse>" },
        expand_all = "e",
        jumpto = "i",
        mark = "m",
        next_failed = "J",
        output = "o",
        prev_failed = "K",
        run = "r",
        run_marked = "R",
        short = "O",
        stop = "u",
        target = "t"
      },
      open = "botright vsplit | vertical resize 50"
    }

I'm assuming Neotest should not have aerial as a dependency but that the most productive way of integrating would be by offering an API that enables the implementation of a plugin e.g. neotest-aerial.nvim that implements the integration.

To do such a thing, I suppose aerial would just need one major thing: a function that can be called that gets passed the current location (in Treesitter's tree, which I don't know much about) where an integrating plugin can then call Neotest callbacks.


I've opened a marker issue on the Neotest repository because (1) this is a broad feature request that may require general assessment between the two projects, and (2) because I'm not sure which side would implement the bulk of the work.

Thank you for your amazing work :bow:

stevearc commented 1 year ago

I'm trying to think of some way that this could be done without either me re-implementing everything in the Neotest summary window or vice-versa. I get the appeal of having everything in one omni-window, but I don't think either plugin wants to add a dependency to the other, and without that it means a lot of logic would have to be implemented twice.

Neotest has a pretty complete API, so it would definitely be possible to run tests, open output, and display results. But the data sources are not exactly guaranteed to match, so we would also need to hook into the Neotest test tree and render that instead of our normal LSP/treesitter symbols. At that point, we've basically just rewritten the summary consumer in a different repo, where it'll be harder to keep in sync. I don't like that idea because it's a ton of duplicative work and a nightmarish maintenance burden.

Unfortunately, I don't think there's a good way to do this, but I'm open to having my mind changed.

rcarriga commented 1 year ago

But the data sources are not exactly guaranteed to match, so we would also need to hook into the Neotest test tree and render that instead of our normal LSP/treesitter symbols.

I think this would be more of an issue going from neotest -> aerial, because tests can exist that don't have a place in the file (e.g. parameterised tests) but for aerial -> neotest, this should actually be relatively simple.

The state consumer could be used to avoid having to integrate with neotest's async code. On aerial rendering, the positions for a buffer is queried for and the nodes are linked by simply using the nearest match. The first node to match a test is considered as the "test" for rendering statuses etc

func test_a (matches test_a)
  variable x (matches test_a, ignored)
  variable y (matches test_a, ignored)

func test_b (matches test_b)
  variable z (matches test_b, ignored)

So for this to be implemented, when aerial renders, the neotest-aerial.nvim would do this linking and hook into aerial to supplement mappings/visual effects. I'm not sure what aerial currently offers in regards to that kind of integration though.

Here's some very rough sample code for matching them

local neotest = require("neotest")

---@class LSPNode
---@field position { row: integer, endRow: integer }

---@param buf integer
---@param lsp_nodes LSPNode[]
function buf_tests(buf, lsp_nodes)
  local adapters = neotest.state.adapter_ids()
  local tree
  for _, adapter_id in ipairs(adapters) do
    tree = neotest.state.positions(adapter_id, { buffer = buf })
    if tree then
      break
    end
  end
  if not tree then
    return {}
  end

  local tests = {}

  for _, elem in tree:iter() do
    if elem.range then
      table.insert(tests, elem)
    end
  end

  local node_tests = {}

  local next_test = table.remove(tests, 1)

  for _, node in ipairs(lsp_nodes) do
    if node.position.row >= next_test.range[1] and node.position.endRow <= next_test.range[3] then
      node_tests[node] = next_test
      next_test = table.remove(tests, 1)
    end
  end

  return node_tests
end
stevearc commented 1 year ago

Thanks for weighing in @rcarriga!

@fnune what are your thoughts on that approach? It's more viable from a maintenance perspective, but the downside would be that there's potential for mismatch between the aerial window and the test summary window. Aerial may not list all of the actual tests that are present in the file.

fnune commented 1 year ago

there's potential for mismatch between the aerial window and the test summary window. Aerial may not list all of the actual tests that are present in the file.

I think this risk is low enough: it's clear to users that the Aerial window displays symbols in the buffer. The aerial window may contain symbols that are not tests, and the tree of tests may contain tests that aren't in the aerial window (as @rcarriga mentioned: parameterized tests, it blocks inside a loop...). However, interacting with the tests is always done through the lens of the buffer: you point at some symbol in the file, and you call "run tests closest to the cursor". To me, that means that the mismatch between the aerial symbols tree and the Neotest tests tree will be immediately understandable by users, and thus OK to live with.

I think the success of this plan hinges rather on the answer to this question from @rcarriga:

So for this to be implemented, when aerial renders, the neotest-aerial.nvim would do this linking and hook into aerial to supplement mappings/visual effects. I'm not sure what aerial currently offers in regards to that kind of integration though.

Does aerial have the affordances needed to do this? E.g. "run a function using the TS symbol under the cursor". Or "display some icon as a prefix in the aerial tree node depending on Neotest status".

stevearc commented 1 year ago

You're right, there aren't currently hooks for rendering custom content inline with the symbols. There are currently sufficient APIs for performing actions, but you'd have to hook a lot of it up yourself.

I don't have much bandwidth these days, but I'll put this in my backlog and hopefully some free time will materialize in the future.

fnune commented 1 year ago

I'm trying to build a basic integration that just asks Neotest to run a test using Aerial. Looking at your snippet above, @rcarriga: what's meant by LSPNode? Where do I get those in an LSP-agnostic way?

Sorry, I haven't built a plugin before. It's enough for me if you point me toward some material and I'll try to figure it out.

stevearc commented 1 year ago

@fnune I'll give you a couple pointers for something quick and dirty. Note that these do not use public API methods, so expect that it may change in the future (though hopefully by then there will be a better way to do this)

If you're in the aerial window, you can get the symbol under the cursor with

local symbol = require('aerial.data').get(0):item(vim.api.nvim_win_get_cursor(0)[1])

It will have type aerial.Symbol, defined here https://github.com/stevearc/aerial.nvim/blob/7c2a432238b9c8e8c526619fa003e658691ea127/lua/aerial/data.lua#L6-L19

So that comes with lnum and end_lnum. You can get the buffer for the source code with require("aerial.util").get_source_buffer(). From there, it should be pretty straightforward to take the buffer & position and get neotest to run the closest test to that location.