lexical-lsp / lexical

Lexical is a next-generation elixir language server
776 stars 77 forks source link

Alias code actions put first alias on the same line as the last #765

Closed alcolmenar closed 2 weeks ago

alcolmenar commented 4 weeks ago

Firstly I wanted to say thank you for all the work that the team has been putting into lexical and I've been using it lately since it seems to just work and has more features that the other ones

I was trying out this Organize Aliases code action but it seems I'm running into a bug where the first alias gets placed on the same line as the last alias in the block shown below

Before:

image

After:

image

It may not be related to Organize Aliases because this also happens when using a code action to add missing aliases.

I actually tried to create a test the code snippet and it looked like it organized it properly. I would've liked to put out a PR to fix it, but I'm not super familiar with the code and it'll take me some time to get caught up. Maybe I'll look into it more this weekend

nvim version: 0.10 macos 14.4.1 lexical built from source @ cc5c2f91b621e55f1b325d51fbce4fc6f0f822b5

scottming commented 4 weeks ago

We discussed this issue on Discord. It seems to be a bug in nvim. @alcolmenar It would be great if you could help by submitting an issue in the nvim repository.

It appears that both nvim and the Zed editor cannot recognize the case where new_text is \n. Take this example:

defmodule Dummy do
  alias B
  alias A

  def hello do
  end
end

CleanShot 2024-06-02 at 08 40 12@2x

This is the message that is returned by lexical:

[DEBUG][2024-06-02 08:39:45] .../vim/lsp/rpc.lua:387    "rpc.receive"   {
  id = 10,
  jsonrpc = "2.0",
  result = { {
      edit = {
        changes = {
          ["file:///Users/scottming/Code/dummy/lib/dummy.ex"] = { {
              newText = "",
              range = {
                ["end"] = {
                  character = 0,
                  line = 3
                },
                start = {
                  character = 0,
                  line = 2
                }
              }
            }, {
              newText = "\n",
              range = {
                ["end"] = {
                  character = 0,
                  line = 2
                },
                start = {
                  character = 0,
                  line = 1
                }
              }
            }, {
              newText = "  alias A\n  alias B",
              range = {
                ["end"] = {
                  character = 0,
                  line = 1
                },
                start = {
                  character = 0,
                  line = 1
                }
              }
            } }
        }
      },
      kind = "source.organizeImports",
      title = "Organize aliases"
    } }
}
alcolmenar commented 4 weeks ago

Thank you for the response and looking into this @scottming. I ended up spending some time digging into this one a bit more and found that the issue is that Neovim sorts the TextEdits that are received from the LSP prior to applying them. https://github.com/neovim/neovim/blob/master/runtime/lua/vim/lsp/util.lua#L385-L396

The logic attempts to reverse sort the edits by their start line, but if there are multiple edits with the same start line, those edits are reversed.

I was able to capture the result of the sorting below:

{ {    _index = 1,    newText = "",    range = {      ["end"] = {        character = 0,
       line = 9      },      start = {        character = 0,        line = 2      }    }  }, {    _index = 3,    newText = "  alias Lexical.Ast\n  alias Lexical
.Document\n  alias Lexical.Document.Position\n  alias Lexical.Project\n  alias Lexical.Protocol.Types.Completion\n  alias Lexical.RemoteControl\n  alias Lexical
.RemoteControl.Search\n  alias Lexical.Server.CodeIntelligence",    range = {      ["end"] = {        character = 0,        line = 1      },      start = {
   character = 0,        line = 1      }    }  }, {    _index = 2,    newText = "\n",    range = {      ["end"] = {        character = 0,        line = 2      }
,      start = {        character = 0,        line = 1      }    }  } }

Prior to sorting, the edits are annotated with an _index. From the excerpt, after sorting, index 3 is before index 2. Applying the edits in this order results in the organize aliases seemingly moving the 1st alias to the end of the last.

In the end, I think the text edits sent from the LSP should be mutually exclusive so that there can't be any confusion on the order they should be applied. What do you think?

scottming commented 3 weeks ago

I'm not sure if mutual exclusion is helpful in this case. Using the example above, should these two text edits be combined into one? @scohen , what do you think?

{
              newText = "\n",
              range = {
                ["end"] = {
                  character = 0,
                  line = 2
                },
                start = {
                  character = 0,
                  line = 1
                }
              }
            }, {
              newText = "  alias A\n  alias B",
              range = {
                ["end"] = {
                  character = 0,
                  line = 1
                },
                start = {
                  character = 0,
                  line = 1
                }
              }
scohen commented 3 weeks ago

Thank you for the response and looking into this @scottming. I ended up spending some time digging into this one a bit more and found that the issue is that Neovim sorts the TextEdits that are received from the LSP prior to applying them.

Why on earth would neovim do this? This is blatantly incorrect behavior, those edits are supposed to be applied in the order they were received, which is why they're sent as a list.

scohen commented 3 weeks ago

From the LSP spec:

All text edits ranges refer to positions in the document they are computed on. They therefore move a document from state S1 to S2 without describing any intermediate state. Text edits ranges must never overlap, that means no part of the original document must be manipulated by more than one edit. However, it is possible that multiple edits have the same start position: multiple inserts, or any number of inserts followed by a single remove or replace edit. If multiple inserts have the same position, the order in the array defines the order in which the inserted strings appear in the resulting text.

The last sentence is what's important here, sorting the array breaks that rule.

scohen commented 3 weeks ago

@scottming

@scohen , what do you think?

I'd prefer that neovim follow the spec, otherwise we'll have to follow a bunch of rules that aren't really defined anywhere but inside the code of various editors. Most other editors get this right, and we've had consistent problems with neovim not following the spec.

scohen commented 3 weeks ago

...and the edit you proposed won't work, you'll have the word alias on line 3 left over. The ideal edit here would be

{
  "newtext": "alias A\n alias B\n",
  "range": {
    "start": {"line": 1, "character": 0},
    "end": {"line": 3, "character": 0}
  }
}
scottming commented 3 weeks ago

I'd prefer that neovim follow the spec, otherwise we'll have to follow a bunch of rules that aren't really defined anywhere but inside the code of various editors. Most other editors get this right, and we've had consistent problems with neovim not following the spec.

I agree.

scohen commented 3 weeks ago

Did either @scottming or @alcolmenar open a bug in the neovim repo? I'd like to close this.

alcolmenar commented 3 weeks ago

Created the issue above and opened up a PR to fix it as well.

https://github.com/neovim/neovim/pull/29212

scohen commented 3 weeks ago

Thanks, @alcolmenar !

scohen commented 2 weeks ago

This has been fixed in neovim.