diff --git a/lib/next_ls.ex b/lib/next_ls.ex index 150ad615..bf2745f7 100644 --- a/lib/next_ls.ex +++ b/lib/next_ls.ex @@ -133,7 +133,28 @@ defmodule NextLS do for {pid, _} <- entries do case Definition.fetch(URI.parse(uri).path, {position.line + 1, position.character + 1}, pid) do nil -> - nil + case NextLS.ASTHelpers.Variables.get_variable_definition( + URI.parse(uri).path, + {position.line + 1, position.character + 1} + ) do + {_name, {startl..endl, startc..endc}} -> + %Location{ + uri: "file://#{URI.parse(uri).path}", + range: %Range{ + start: %Position{ + line: startl - 1, + character: startc - 1 + }, + end: %Position{ + line: endl - 1, + character: endc - 1 + } + } + } + + _other -> + nil + end [] -> nil @@ -232,7 +253,11 @@ defmodule NextLS do ) :unknown -> - [] + file + |> NextLS.ASTHelpers.Variables.list_variable_references({line, col}) + |> Enum.map(fn {_name, {startl..endl, startc..endc}} -> + [file, startl, endl, startc, endc] + end) end for [file, startl, endl, startc, endc] <- references, match?({:ok, _}, File.stat(file)) do diff --git a/lib/next_ls/helpers/ast_helpers/variables.ex b/lib/next_ls/helpers/ast_helpers/variables.ex new file mode 100644 index 00000000..8e115927 --- /dev/null +++ b/lib/next_ls/helpers/ast_helpers/variables.ex @@ -0,0 +1,223 @@ +defmodule NextLS.ASTHelpers.Variables do + @moduledoc false + + @scope_breaks ~w(defmodule defprotocol defimpl defdelegate fn if unless case cond for with receive try quote)a + @defs_with_args ~w(def defp defmacro defmacrop)a + @blocks ~w(do catch rescue after else)a + @scope_ends [:->] ++ @scope_breaks ++ @defs_with_args + + @spec get_variable_definition(String.t(), {integer(), integer()}) :: {atom(), {Range.t(), Range.t()}} | nil + def get_variable_definition(file, position) do + file = File.read!(file) + ast = Code.string_to_quoted!(file, columns: true) + + {_ast, %{vars: vars}} = + Macro.traverse( + ast, + %{vars: [], symbols: %{}, sym_ranges: [], scope: []}, + &prewalk/2, + &postwalk/2 + ) + + Enum.find_value(vars, fn %{name: name, sym_range: range, ref_range: ref_range} -> + if position_in_range?(position, ref_range), do: {name, range}, else: nil + end) + end + + @spec list_variable_references(String.t(), {integer(), integer()}) :: [{atom(), {Range.t(), Range.t()}}] + def list_variable_references(file, position) do + file = File.read!(file) + ast = Code.string_to_quoted!(file, columns: true) + + {_ast, %{vars: vars}} = + Macro.traverse( + ast, + %{vars: [], symbols: %{}, sym_ranges: [], scope: []}, + &prewalk/2, + &postwalk/2 + ) + + symbol = + Enum.find_value(vars, fn %{name: name, sym_range: range, ref_range: ref_range} -> + if position_in_range?(position, ref_range), do: {name, range}, else: nil + end) + + position = + case symbol do + nil -> position + {_, {line.._, column.._}} -> {line, column} + end + + Enum.reduce(vars, [], fn val, acc -> + if position_in_range?(position, val.sym_range) do + [{val.name, val.ref_range} | acc] + else + acc + end + end) + end + + # search symbols in function and macro definition args and increase scope + defp prewalk({operation, meta, [args | _]} = ast, acc) when operation in @defs_with_args do + acc = increase_scope_nesting(acc, meta[:line]) + acc = find_symbols(args, acc) + {ast, acc} + end + + # special case for 'cond', don't search for symbols in left side of 'cond' clause + defp prewalk({:->, meta, _} = ast, %{scope: ["cond" <> _ | _]} = acc) do + acc = increase_scope_nesting(acc, meta[:line]) + {ast, acc} + end + + # search symbols in a left side of forward arrow clause and increase scope + defp prewalk({:->, meta, [left, _right]} = ast, acc) do + acc = increase_scope_nesting(acc, meta[:line]) + acc = find_symbols(left, acc) + {ast, acc} + end + + # special case for 'cond' + defp prewalk({:cond, meta, _args} = ast, acc) do + acc = increase_scope_nesting(acc, "cond#{meta[:line]}") + {ast, acc} + end + + # increase scope on enter + defp prewalk({operation, meta, _args} = ast, acc) when operation in @scope_breaks do + acc = increase_scope_nesting(acc, meta[:line]) + {ast, acc} + end + + # special case for 'cond' + defp prewalk({:do, _args} = ast, %{scope: ["cond" <> _ | _]} = acc) do + acc = increase_scope_nesting(acc, "conddo") + {ast, acc} + end + + # increase scope on enter 'do/end' block + defp prewalk({operation, _args} = ast, acc) when operation in @blocks do + acc = increase_scope_nesting(acc, operation) + {ast, acc} + end + + # search symbols inside left side of a match or <- and fix processig sequence + defp prewalk({operation, meta, [left, right]}, acc) when operation in [:=, :<-, :destructure] do + acc = find_symbols(left, acc) + {{operation, meta, [right, left]}, acc} + end + + # exclude attribute macro from variable search + defp prewalk({:@, _, _}, acc) do + {nil, acc} + end + + # find variable + defp prewalk({name, meta, nil} = ast, acc) do + range = calculate_range(name, meta[:line], meta[:column]) + type = if range in acc.sym_ranges, do: :sym, else: :ref + var = {type, name, range, acc.scope} + + acc = collect_var(acc, var) + + {ast, acc} + end + + defp prewalk(ast, acc), do: {ast, acc} + + # decrease scope when exiting it + defp postwalk({operation, _, _} = ast, acc) when operation in @scope_ends do + acc = decrease_scope_nesting(acc) + {ast, acc} + end + + # decrease scope when exiting 'do/else' block + defp postwalk({operation, _} = ast, acc) when operation in @blocks do + acc = decrease_scope_nesting(acc) + {ast, acc} + end + + defp postwalk(ast, acc), do: {ast, acc} + + defp find_symbols(ast, acc) do + {_ast, acc} = Macro.prewalk(ast, acc, &find_symbol/2) + acc + end + + defp find_symbol({operation, _, _}, acc) when operation in [:^, :unquote] do + {nil, acc} + end + + # exclude right side of 'when' from symbol search + defp find_symbol({:when, _, [left, _right]}, acc) do + {left, acc} + end + + defp find_symbol({name, meta, nil} = ast, acc) do + range = calculate_range(name, meta[:line], meta[:column]) + acc = Map.update!(acc, :sym_ranges, &[range | &1]) + {ast, acc} + end + + defp find_symbol(ast, acc), do: {ast, acc} + + defp calculate_range(name, line, column) do + length = name |> to_string() |> String.length() + + {line..line, column..(column + length - 1)} + end + + defp position_in_range?({position_line, position_column}, {range_lines, range_columns}) do + position_line in range_lines and position_column in range_columns + end + + defp in_scope?(inner_scope, outer_scope) do + outer = Enum.reverse(outer_scope) + inner = Enum.reverse(inner_scope) + List.starts_with?(inner, outer) + end + + defp increase_scope_nesting(acc, identifier) do + Map.update!(acc, :scope, &[to_string(identifier) | &1]) + end + + defp decrease_scope_nesting(acc) do + Map.update!(acc, :scope, &tl(&1)) + end + + # add new symbol with scope + defp collect_var(acc, {:sym, name, range, scope}) do + symbol = %{ + range: range, + scope: scope + } + + update_in(acc, [:symbols, name], fn + nil -> [symbol] + vals -> [symbol | vals] + end) + end + + # ignore reference which was not defined yet + defp collect_var(%{symbols: symbols} = acc, {:ref, name, _, _}) when not is_map_key(symbols, name), do: acc + + # find symbol for current reference and save sym/ref pair + # remove symbol scopes if reference is from outer scope + defp collect_var(acc, {:ref, name, range, scope}) do + case Enum.split_while(acc.symbols[name], &(not in_scope?(scope, &1.scope))) do + {_, []} -> + acc + + {_, symbols_in_scope} -> + var_pair = %{ + name: name, + sym_range: hd(symbols_in_scope).range, + ref_range: range + } + + acc + |> Map.update!(:vars, &[var_pair | &1]) + |> Map.update!(:symbols, &%{&1 | name => symbols_in_scope}) + end + end +end diff --git a/test/next_ls/definition_test.exs b/test/next_ls/definition_test.exs index d7fa3f81..9a6fe315 100644 --- a/test/next_ls/definition_test.exs +++ b/test/next_ls/definition_test.exs @@ -621,4 +621,71 @@ defmodule NextLS.DefinitionTest do 500 end end + + describe "local variables" do + @describetag root_paths: ["my_proj"] + setup %{tmp_dir: tmp_dir} do + File.mkdir_p!(Path.join(tmp_dir, "my_proj/lib")) + File.write!(Path.join(tmp_dir, "my_proj/mix.exs"), mix_exs()) + [cwd: tmp_dir] + end + + setup %{cwd: cwd} do + bar = Path.join(cwd, "my_proj/lib/bar.ex") + + File.write!(bar, """ + defmodule Bar do + @my_attr 1 + + def run({:ok, alpha} = bravo) do + if @my_attr == 1 do + charlie = "Something: " <> alpha + + {:ok, charlie} + else + bravo + end + end + end + """) + + [bar: bar] + end + + setup :with_lsp + + test "go to local variable definition", %{client: client, bar: bar} do + assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) + assert_request(client, "client/registerCapability", fn _params -> nil end) + assert_notification "$/progress", %{"value" => %{"kind" => "end", "message" => "Finished indexing!"}} + + uri = uri(bar) + + request(client, %{ + method: "textDocument/definition", + id: 4, + jsonrpc: "2.0", + params: %{ + position: %{line: 7, character: 12}, + textDocument: %{uri: uri} + } + }) + + assert_result 4, + %{ + "range" => %{ + "start" => %{ + "line" => 5, + "character" => 6 + }, + "end" => %{ + "line" => 5, + "character" => 12 + } + }, + "uri" => ^uri + }, + 500 + end + end end diff --git a/test/next_ls/helpers/ast_helpers/variables_test.exs b/test/next_ls/helpers/ast_helpers/variables_test.exs new file mode 100644 index 00000000..e8065aab --- /dev/null +++ b/test/next_ls/helpers/ast_helpers/variables_test.exs @@ -0,0 +1,336 @@ +defmodule NextLS.ASTHelpers.VariablesTest do + use ExUnit.Case, async: true + + alias NextLS.ASTHelpers.Variables + + @moduletag :tmp_dir + + setup %{tmp_dir: tmp_dir} do + File.mkdir_p!(Path.join(tmp_dir, "my_proj/lib")) + source = Path.join(tmp_dir, "my_proj/lib/bar.ex") + + File.write!(source, """ + defmodule Bar do + @alpha 123 + + def foo1(%{bravo: bravo} = alpha) when is_nil(bravo) do + charlie = 1 + %{charlie: ^charlie, delta: delta} = alpha + {:ok, alpha, bravo, charlie, delta} + end + + def foo2(charlie) do + alpha = charlie + + bravo = + if alpha == @alpha do + alpha = :ok + bravo = @alpha + IO.inspect(alpha) + IO.inspect(bravo) + else + alpha = :error + IO.inspect(alpha) + end + + {:ok, alpha, bravo} + end + + def foo3 do + alpha = foo4() + bravo = 1 + charlie = 1 + + case alpha do + :ok = bravo when is_atom(bravo) -> + charlie = bravo + IO.inspect(charlie) + + bravo -> + IO.inspect(bravo) + :error + end + + {:ok, bravo, charlie} + end + + defp foo4 do + alpha = Enum.random(1..10) + bravo = :ok + + charlie = + cond do + alpha == 5 -> + bravo + + true -> + :error + end + + IO.inspect(alpha) + charlie + end + + def foo5(alpha) do + bravo = 1 + + for alpha <- alpha do + IO.inspect(alpha) + end + + for bravo <- alpha, charlie <- [1, 2, 3], bravo < charlie do + IO.inspect(charlie) + end + + with {:ok, delta} <- alpha, + [delta, tail] <- delta do + IO.inspect(delta) + IO.inspect(tail) + else + error -> {:error, error} + end + + {:ok, bravo, alpha} + end + + def foo6(alpha) do + bravo = fn + charlie, {:ok, delta} = alpha -> + IO.inspect(alpha) + {:ok, charlie, delta} + + charlie, {:error, delta} = alpha -> + IO.inspect(alpha) + {:error, charlie, delta} + end + + echo = + alpha + |> Enum.map(fn alpha -> {:ok, alpha} end) + |> Enum.filter(fn alpha -> match?({:ok, _}, alpha) end) + + {:ok, bravo.(1, alpha), echo} + end + + def foo7(alpha) do + receive do + {:selector, bravo, charlie} when is_integer(charlie) -> + alpha = 1 + {alpha, bravo} + + bravo -> + {alpha, bravo} + after + 5000 -> + IO.puts(alpha) + end + + charlie = 2 + destructure([alpha, ^charlie], [1, 2, 3]) + IO.inspect(alpha) + end + + defmacro initialize_to_char_count(variables) do + Enum.map(variables, fn name -> + var = Macro.var(name, nil) + length = name |> Atom.to_string() |> String.length() + + quote do + unquote(var) = unquote(length) + end + end) + end + end + """) + + [source: source] + end + + describe "get_variable_definition/2" do + test "symbol defined in a match is found", %{source: source} do + symbol = Variables.get_variable_definition(source, {7, 25}) + assert symbol == {:charlie, {5..5, 5..11}} + end + + test "returns nil when position is not a variable reference", %{source: source} do + symbol = Variables.get_variable_definition(source, {7, 6}) + assert symbol == nil + end + + test "returns nil when position is a variable symbol", %{source: source} do + symbol = Variables.get_variable_definition(source, {5, 5}) + assert symbol == nil + end + end + + describe "list_variable_references/2" do + test "references that defined by same symbol as target reference", %{source: source} do + refs = Variables.list_variable_references(source, {6, 17}) + assert length(refs) == 2 + assert {:charlie, {6..6, 17..23}} in refs + assert {:charlie, {7..7, 25..31}} in refs + end + + test "symbol set in a match and corrctly processing ^", %{source: source} do + refs = Variables.list_variable_references(source, {5, 5}) + assert length(refs) == 2 + assert {:charlie, {6..6, 17..23}} in refs + assert {:charlie, {7..7, 25..31}} in refs + end + + test "symbol set in a function arguments", %{source: source} do + refs = Variables.list_variable_references(source, {4, 30}) + assert length(refs) == 2 + assert {:alpha, {6..6, 42..46}} in refs + assert {:alpha, {7..7, 11..15}} in refs + end + + test "symbol set in a function arguments and referenced in 'when' clause", %{source: source} do + refs = Variables.list_variable_references(source, {4, 21}) + assert length(refs) == 2 + assert {:bravo, {4..4, 49..53}} in refs + assert {:bravo, {7..7, 18..22}} in refs + end + + test "symbol set in a mattern match", %{source: source} do + refs = Variables.list_variable_references(source, {6, 33}) + assert length(refs) == 1 + assert {:delta, {7..7, 34..38}} in refs + end + + test "references shadowed by 'if/else' blocks", %{source: source} do + refs = Variables.list_variable_references(source, {11, 5}) + assert length(refs) == 2 + assert {:alpha, {14..14, 10..14}} in refs + assert {:alpha, {24..24, 11..15}} in refs + end + + test "symbol set in 'if' block", %{source: source} do + refs = Variables.list_variable_references(source, {15, 9}) + assert length(refs) == 1 + assert {:alpha, {17..17, 20..24}} in refs + end + + test "symbol set in match with 'if' containing it's shadow", %{source: source} do + refs = Variables.list_variable_references(source, {13, 5}) + assert length(refs) == 1 + assert {:bravo, {24..24, 18..22}} in refs + end + + test "symbol set in 'case' clause", %{source: source} do + refs = Variables.list_variable_references(source, {33, 13}) + assert length(refs) == 2 + assert {:bravo, {33..33, 32..36}} in refs + assert {:bravo, {34..34, 19..23}} in refs + end + + test "symbol referenced in 'cond' clause", %{source: source} do + refs = Variables.list_variable_references(source, {46, 5}) + assert length(refs) == 2 + assert {:alpha, {51..51, 9..13}} in refs + assert {:alpha, {58..58, 16..20}} in refs + end + + test "symbol shadowed in 'for' and 'with'", %{source: source} do + refs = Variables.list_variable_references(source, {62, 12}) + assert length(refs) == 4 + assert {:alpha, {65..65, 18..22}} in refs + assert {:alpha, {69..69, 18..22}} in refs + assert {:alpha, {73..73, 26..30}} in refs + assert {:alpha, {81..81, 18..22}} in refs + + refs2 = Variables.list_variable_references(source, {63, 5}) + assert length(refs2) == 1 + assert {:bravo, {81..81, 11..15}} in refs2 + end + + test "symbol defined in 'for'", %{source: source} do + refs = Variables.list_variable_references(source, {65, 9}) + assert length(refs) == 1 + assert {:alpha, {66..66, 18..22}} in refs + + refs2 = Variables.list_variable_references(source, {69, 9}) + assert length(refs2) == 1 + assert {:bravo, {69..69, 47..51}} in refs2 + + refs3 = Variables.list_variable_references(source, {69, 25}) + assert length(refs3) == 2 + assert {:charlie, {69..69, 55..61}} in refs3 + assert {:charlie, {70..70, 18..24}} in refs3 + end + + test "symbol defined in 'with'", %{source: source} do + refs = Variables.list_variable_references(source, {73, 16}) + assert length(refs) == 1 + assert {:delta, {74..74, 27..31}} in refs + + refs2 = Variables.list_variable_references(source, {74, 11}) + assert length(refs2) == 1 + assert {:delta, {75..75, 18..22}} in refs2 + + refs3 = Variables.list_variable_references(source, {78, 7}) + assert length(refs3) == 1 + assert {:error, {78..78, 25..29}} in refs3 + end + + test "symbol shadowed by anonymous funciton", %{source: source} do + refs = Variables.list_variable_references(source, {84, 12}) + assert length(refs) == 2 + assert {:alpha, {96..96, 7..11}} in refs + assert {:alpha, {100..100, 21..25}} in refs + end + + test "symbol defined in anonymous funciton", %{source: source} do + refs = Variables.list_variable_references(source, {86, 7}) + assert length(refs) == 1 + assert {:charlie, {88..88, 15..21}} in refs + + refs2 = Variables.list_variable_references(source, {86, 22}) + assert length(refs2) == 1 + assert {:delta, {88..88, 24..28}} in refs2 + + refs3 = Variables.list_variable_references(source, {86, 31}) + assert length(refs3) == 1 + assert {:alpha, {87..87, 20..24}} in refs3 + + refs4 = Variables.list_variable_references(source, {97, 22}) + assert length(refs4) == 1 + assert {:alpha, {97..97, 37..41}} in refs4 + + refs5 = Variables.list_variable_references(source, {98, 25}) + assert length(refs5) == 1 + assert {:alpha, {98..98, 51..55}} in refs5 + end + + test "symbols with 'receive' macro", %{source: source} do + refs = Variables.list_variable_references(source, {103, 12}) + assert length(refs) == 2 + assert {:alpha, {110..110, 10..14}} in refs + assert {:alpha, {113..113, 17..21}} in refs + + refs2 = Variables.list_variable_references(source, {105, 19}) + assert length(refs2) == 1 + assert {:bravo, {107..107, 17..21}} in refs2 + end + + test "symbols set with 'destructure'", %{source: source} do + refs = Variables.list_variable_references(source, {117, 18}) + assert length(refs) == 1 + assert {:alpha, {118..118, 16..20}} in refs + end + + test "symbols set in macro", %{source: source} do + refs = Variables.list_variable_references(source, {121, 37}) + assert length(refs) == 1 + assert {:variables, {122..122, 14..22}} in refs + + refs2 = Variables.list_variable_references(source, {124, 7}) + assert length(refs2) == 1 + assert {:length, {127..127, 32..37}} in refs2 + + refs3 = Variables.list_variable_references(source, {123, 7}) + assert length(refs3) == 1 + assert {:var, {127..127, 17..19}} in refs3 + end + end +end diff --git a/test/next_ls/references_test.exs b/test/next_ls/references_test.exs index 3543280a..f27cece9 100644 --- a/test/next_ls/references_test.exs +++ b/test/next_ls/references_test.exs @@ -42,7 +42,13 @@ defmodule NextLS.ReferencesTest do end def foo2 do - {:error, @foo_attr} + alpha = 1 + bravo = 2 + charlie = alpha + bravo + delta = alpha + + alpha = false + {:error, @foo_attr, alpha} end end """) @@ -158,8 +164,49 @@ defmodule NextLS.ReferencesTest do %{ "uri" => uri, "range" => %{ - "start" => %{"line" => 15, "character" => 13}, - "end" => %{"line" => 15, "character" => 21} + "start" => %{"line" => 21, "character" => 13}, + "end" => %{"line" => 21, "character" => 21} + } + } + ] + ) + end + + test "list variable references", %{client: client, bar: bar} = context do + assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) + assert_request(client, "client/registerCapability", fn _params -> nil end) + assert_is_ready(context, "my_proj") + assert_compiled(context, "my_proj") + assert_notification "$/progress", %{"value" => %{"kind" => "end", "message" => "Finished indexing!"}} + + request(client, %{ + method: "textDocument/references", + id: 4, + jsonrpc: "2.0", + params: %{ + position: %{line: 15, character: 5}, + textDocument: %{uri: uri(bar)}, + context: %{includeDeclaration: true} + } + }) + + uri = uri(bar) + + assert_result2( + 4, + [ + %{ + "uri" => uri, + "range" => %{ + "start" => %{"line" => 17, "character" => 14}, + "end" => %{"line" => 17, "character" => 18} + } + }, + %{ + "uri" => uri, + "range" => %{ + "start" => %{"line" => 18, "character" => 12}, + "end" => %{"line" => 18, "character" => 16} } } ]