Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: expose manipulate pipes command #521

Merged

Conversation

polvalente
Copy link
Contributor

This PR also adds a test that shows a bug which seems to be in Macro.to_string itself :( I've opened an issue in the Elixir repo elixir-lang/elixir#10864 for discussion. Maybe this is expected and is something we have to deal with

@polvalente
Copy link
Contributor Author

It seems that this fix is needed: elixir-lang/elixir#10864 (comment)
Let's wait for a response in the issue before merging this PR

@polvalente
Copy link
Contributor Author

So, a commit was added changing this behavior in Elixir. Due to the fact that we need to be retrocompatible, I've added a function to treat added escapes whenever they are added. The main assumption is that from_pipe and to_pipe should never change the number of non-whitespace chars besides adding or removing |>, so if the number of \ changes, it means that we need to execute said replacement.

@@ -64,4 +64,17 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes.AST d
defp do_to_pipe(node, acc) do
{node, acc}
end

defp fix_escape_chars(parsed, original) do
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fix doesn't work properly because sigil delimiters changed to " prior to 1.10

Some parsing of the AST will be needed to treat sigils

@@ -64,4 +64,77 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes.AST d
defp do_to_pipe(node, acc) do
{node, acc}
end

def ast_to_string(ast) do
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an attempt to handle escape characters as expected. Local tests passes successfully, but I'm not sure if this is the best way to approach the problem

@axelson
Copy link
Member

axelson commented Apr 3, 2021

I've been trying this out a bit with (https://github.com/elixir-lsp/vscode-elixir-ls/pull/182/files) and I'm running into some issues. It appears that sometimes the extracted text . Here's a partial failing test (partial because it should check the result):

    test "can handle multiple calls" do
      {:ok, _} =
        JsonRpcMock.start_link(success_reply: {:ok, %{"applied" => true}}, test_pid: self())

      uri = "file:/some_file.ex"

      text = """
      defmodule BasicEx do
        def add(a) do
          require Logger

          Logger.info(to_string(add_num(a, 12)))
        end

        def add_num(a, num), do: a + num
      end
      """

      assert {:ok, nil} =
               ManipulatePipes.execute(
                 ["fromPipe", uri, 4, 16],
                 %Server{
                   source_files: %{
                     uri => %SourceFile{
                       text: text
                     }
                   }
                 }
               )

That test results in:

code_string: "Logger.info(to_string(add_num(a, 12))"

  1) test execute/2 fromPipe can handle multiple calls (ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipesTest)
     test/providers/execute_command/manipulate_pipes_test.exs:763
     ** (TokenMissingError) nofile:1: missing terminator: ) (for "(" starting at line 1)
     code: ManipulatePipes.execute(
     stacktrace:
       lib/language_server/providers/execute_command/manipulate_pipes/ast.ex:23: ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes.AST.from_pipe/1
       lib/language_server/providers/execute_command/manipulate_pipes.ex:122: ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes.from_pipe_at_cursor/3
       lib/language_server/providers/execute_command/manipulate_pipes.ex:31: ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes.execute/2
       test/providers/execute_command/manipulate_pipes_test.exs:782: (test)

Where I would expect the value of code_string to instead be more like Logger.info(to_string(add_num(a, 12))) (in actuality it is missing a paren). I think a good test would be to run the same command, but once with each character position and ensure that the same value is output each time (which is what I would expect as a user running extension.toPipe on a line without a ;)

@polvalente
Copy link
Contributor Author

@axelson could you elaborate on "once with each character position"? Do you mean something like for column <- 0..line_length-1, do: ..., or just the "valid" columns for a given "to pipe"/"from pipe"?

The command is currently highly dependent on where the cursor is because there can be multiple function calls and multple pipes in a line. Anyway, your case is indeed a bug and I'll investigate on what's causing it

@@ -230,7 +238,11 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes do
col + tail_length
end

{:ok, pipe_call, range(start_line, start_col, end_line, end_col)}
if String.contains?(pipe_call, "|>") do
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@axelson I've added this because your test actually was calling "fromPipe", which shouldn't do anything if there aren't any pipes in the selected text

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another possible option which would require some rework is to scan until a pipe is found, but this could have unexpected results

@@ -280,7 +292,7 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes do
|> String.reverse()
|> String.graphemes()
|> Enum.reduce_while([], fn c, acc ->
if String.match?(c, ~r/\s/) do
if String.match?(c, ~r/[\s\(\[\{]/) do
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was needed if the suggested test is changed to "toPipe", because Logger.info(to_string(...) doesn't have any spaces, but the first ( actually breaks the identifier to_string

"start" => %{"character" => 16, "line" => 4}
}

expected_substitution = "to_string(a |> add_num(12))"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one might bring up some discussion. The way that our macro-walking function is implemented, the code infers that the innermost function call is the one that should be piped.

Does this need to be reworked so the outermost one is the piped call?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess that would be preferred. On the other hand this is an ambiguous case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lukaszsamson I've fixed this in the latest commit. The issue was that there was a and t != [] clause which disallowed 1-arg functions from being piped. No other tests broke, so that's a good sign, I guess

@axelson
Copy link
Member

axelson commented Apr 4, 2021

@axelson could you elaborate on "once with each character position"? Do you mean something like for column <- 0..line_length-1, do: ..., or just the "valid" columns for a given "to pipe"/"from pipe"?

Yeah, for column <- 0..line_length-1, do: ... is exactly what I mean. But since the output may change based on where the cursor is, it would just check that no exception is raised.

The command is currently highly dependent on where the cursor is because there can be multiple function calls and multple pipes in a line. Anyway, your case is indeed a bug and I'll investigate on what's causing it

Yeah, makes sense 👍

@@ -760,4 +889,42 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipesTest d
) == edited_text
end
end

defp assert_never_raises(text, uri, command) do
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@axelson I've added this helper assertion that travels through the whole text calling the corresponding command ("toPipe" or "fromPipe"). This should ensure that no position in each test file raises in the code. Some bugs have been found and fixed this way already :)

# fix which was introduced in
# https://github.com/elixir-lang/elixir/commit/88d82f059756ed1eb56a562fae7092f77d941de8
# is needed for proper treatment of sigils in our use-case.
defp sigil_call({sigil, meta, [{:<<>>, _, _} = parts, args]} = ast, fun)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a TODO with info which part may be removed when we require elixir 1.13 (or 1.12 if the fix gets backported)

Copy link
Member

@axelson axelson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, taking a deeper look at this. Sorry for the delay.

I have a high-level comment, that I wish I had flagged earlier. Given this snippet:

Ash.Changeset.for_create(Track, :create, data)
|> GenTracker.API.create()

Running toPipe on the first line I would expect to end up with

Track
|> Ash.Changeset.for_create(:create, data)
|> GenTracker.API.create()

But instead end up with

Track |> Ash.Changeset.for_create(:create, data)
|> GenTracker.API.create()

But I suppose this would be hard to handle for code that has multiple function calls on one line, so perhaps it's best handled as a future improvement.

Also (with the above code) I would expect the same result no matter where on the first line I called toPipe, but instead there's a bug where if you call toPipe at the end of the line you end up with:

Ash.Changeset.for_create(Track, :create, data)GenTracker.API.create()

Which is obviously wrong and does not compile.

to_pipe_at_cursor and from_pipe_at_cursor suggested changes:

  • For easier testing I think we should make them public functions and call them from the tests.
  • I think we should have those functions return a %ElixirLS.LanguageServer.Protocol.TextEdit{} (which needs to be created) instead a raw map
  • Then those two can be combined with ElixirLS.LanguageServer.Test.TestUtils.apply_text_edit/2 to greatly simplify the tests
  • I have some code related to this that I hope to post soon

@polvalente
Copy link
Contributor Author

Okay, taking a deeper look at this. Sorry for the delay.

I have a high-level comment, that I wish I had flagged earlier. Given this snippet:

Ash.Changeset.for_create(Track, :create, data)
|> GenTracker.API.create()

Running toPipe on the first line I would expect to end up with

Track
|> Ash.Changeset.for_create(:create, data)
|> GenTracker.API.create()

But instead end up with

Track |> Ash.Changeset.for_create(:create, data)
|> GenTracker.API.create()

But I suppose this would be hard to handle for code that has multiple function calls on one line, so perhaps it's best handled as a future improvement.

Thanks for the review!

About changing multi-line to single-line pipes, I'd rather leave that as a future improvement, since most [citation needed] people that will be using this command will also be using "format on save" or similiar features.

Also (with the above code) I would expect the same result no matter where on the first line I called toPipe, but instead there's a bug where if you call toPipe at the end of the line you end up with:

Ash.Changeset.for_create(Track, :create, data)GenTracker.API.create()

Which is obviously wrong and does not compile.

Nice catch! I'll add a test and a fix for this as soon as I can get back to this PR.

to_pipe_at_cursor and from_pipe_at_cursor suggested changes:

  • For easier testing I think we should make them public functions and call them from the tests.

Which tests are you talking about? The integration ones, which receive the command itself?
I guess we could migrate them to a model where there is one test-case which calls it and the others only call these functions. WDYT?

  • I think we should have those functions return a %ElixirLS.LanguageServer.Protocol.TextEdit{} (which needs to be created) instead a raw map
  • Then those two can be combined with ElixirLS.LanguageServer.Test.TestUtils.apply_text_edit/2 to greatly simplify the tests

Sounds good!

  • I have some code related to this that I hope to post soon

Should I wait for said code?

Make `to_pipe_at_cursor/3` public to facilitate testing. I think we
should still keep some integration-level testing, but I think the
majority of the test cases can be simplified. I didn't implement making
`from_pipe_at_cursor/3` public, but I think it would make sense as well.

Introduce `ElixirLS.LanguageServer.Protocol.TextEdit` and return it from
`to_pipe_at_cursor/3` and `from_pipe_at_cursor/3`

Add an example of a working simpler test, and add a few examples of
broken tests (as mentioned in elixir-lsp#521 (review))
Comment on lines +144 to +152
defp get_function_call(line, col, head, cur, original_tail) when cur in ["\n", "\r", "\r\n"] do
{head, new_cur} = String.split_at(head, -1)
get_function_call(line, col - 1, head, new_cur, cur <> original_tail)
end

defp get_function_call(line, col, head, ")", original_tail) do
{head, cur} = String.split_at(head, -1)
get_function_call(line, col - 1, head, cur, ")" <> original_tail)
end
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@axelson here's what caused the tests you added to break: do to how do_get_function_call works, when we're at a closing paren, it will jump and try to pipe the next function call. The same for when we're already at the end of a line.

I'm thinking that maybe the correct fix is to track back the text until we reach a "(" character instead (updating indices properly).

I haven't tested, but this one probably breaks if the line ends as ") \n"

WDYT? Any other ideas?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, tracing back blind yields other problems when the cursor is in the leading whitespace for the line :(

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I"ve added a test which demonstrates the ")\s\n" case still breaking

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do to how do_get_function_call works, when we're at a closing paren, it will jump and try to pipe the next function call. The same for when we're already at the end of a line.

Do we need to keep the "jumping forward" behavior? What is it buying us?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's more of an artifact on how the "function call finding" code works. for instance on a multi-line function call like:

    f(
    x,
    y,
    z)

Having the cursor at the beggining of the line containing x will still find the function correctly.

However, we can just for the cursor to be, for instance at a "(" character or a non-whitespace character, for instance

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, I see.

FYI, I found another breaking case so I'm working on fixing that.

Changed the function call detection to not "jump forward", as part of
this if the user requests an index that is not on a function call, then
no action is performed.

Also fix handling of piping function calls that span multiple lines by
adjusting the returned line and column to point to the correct start
location by counting backward.
@axelson
Copy link
Member

axelson commented Jun 2, 2021

@polvalente okay, I've pushed my proposed changes to a new PR: polvalente#2

This makes all the tests pass, but I want to do some more local testing as well.

@polvalente polvalente force-pushed the fix/expose-manipulate-pipes-command branch from 37de054 to 6599fb8 Compare June 2, 2021 12:45
@axelson
Copy link
Member

axelson commented Jun 2, 2021

@lukaszsamson Would you mind taking another look at the code as well? It's changed a bit since your review.

@lukaszsamson
Copy link
Collaborator

@axelson Looks good to me, LGTM

@axelson axelson merged commit f77999b into elixir-lsp:master Jun 27, 2021
@polvalente polvalente deleted the fix/expose-manipulate-pipes-command branch June 28, 2021 12:58
vanjabucic pushed a commit to vanjabucic/elixir-ls that referenced this pull request Jul 5, 2021
* fix: expose manipulate pipes command

* test: add test for known bug

* fix: make test retrocompatible

* feat: treat escape chars after macro to_string

* docs: add comment for treatment

* feat: import code from Elixir source code to deal with sigils

* fix: use ast_to_string instead of Macro.to_string

* chore: remove redundant test

* fix: various fixes due to new tests

* test: make test retrocompatible

* fix: do not raise for any position in the file

* docs: add todo comment

* fix: also pipe 1-arg functions

* test: fix test assertion

* Proposed changes

Make `to_pipe_at_cursor/3` public to facilitate testing. I think we
should still keep some integration-level testing, but I think the
majority of the test cases can be simplified. I didn't implement making
`from_pipe_at_cursor/3` public, but I think it would make sense as well.

Introduce `ElixirLS.LanguageServer.Protocol.TextEdit` and return it from
`to_pipe_at_cursor/3` and `from_pipe_at_cursor/3`

Add an example of a working simpler test, and add a few examples of
broken tests (as mentioned in elixir-lsp#521 (review))

* chore: unbreak tests

* fix: make tests pass

* test: add broken test

* Handle more cases for manipulating pipes

Changed the function call detection to not "jump forward", as part of
this if the user requests an index that is not on a function call, then
no action is performed.

Also fix handling of piping function calls that span multiple lines by
adjusting the returned line and column to point to the correct start
location by counting backward.

* chore: add suggestions from code review

* chore: format code

Co-authored-by: Jason Axelson <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants