Skip to content

Commit 7099370

Browse files
authored
feat(definition,references): local variables (#253)
1 parent 67d33ea commit 7099370

File tree

5 files changed

+703
-5
lines changed

5 files changed

+703
-5
lines changed

lib/next_ls.ex

+27-2
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,28 @@ defmodule NextLS do
133133
for {pid, _} <- entries do
134134
case Definition.fetch(URI.parse(uri).path, {position.line + 1, position.character + 1}, pid) do
135135
nil ->
136-
nil
136+
case NextLS.ASTHelpers.Variables.get_variable_definition(
137+
URI.parse(uri).path,
138+
{position.line + 1, position.character + 1}
139+
) do
140+
{_name, {startl..endl, startc..endc}} ->
141+
%Location{
142+
uri: "file://#{URI.parse(uri).path}",
143+
range: %Range{
144+
start: %Position{
145+
line: startl - 1,
146+
character: startc - 1
147+
},
148+
end: %Position{
149+
line: endl - 1,
150+
character: endc - 1
151+
}
152+
}
153+
}
154+
155+
_other ->
156+
nil
157+
end
137158

138159
[] ->
139160
nil
@@ -232,7 +253,11 @@ defmodule NextLS do
232253
)
233254

234255
:unknown ->
235-
[]
256+
file
257+
|> NextLS.ASTHelpers.Variables.list_variable_references({line, col})
258+
|> Enum.map(fn {_name, {startl..endl, startc..endc}} ->
259+
[file, startl, endl, startc, endc]
260+
end)
236261
end
237262

238263
for [file, startl, endl, startc, endc] <- references, match?({:ok, _}, File.stat(file)) do
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
defmodule NextLS.ASTHelpers.Variables do
2+
@moduledoc false
3+
4+
@scope_breaks ~w(defmodule defprotocol defimpl defdelegate fn if unless case cond for with receive try quote)a
5+
@defs_with_args ~w(def defp defmacro defmacrop)a
6+
@blocks ~w(do catch rescue after else)a
7+
@scope_ends [:->] ++ @scope_breaks ++ @defs_with_args
8+
9+
@spec get_variable_definition(String.t(), {integer(), integer()}) :: {atom(), {Range.t(), Range.t()}} | nil
10+
def get_variable_definition(file, position) do
11+
file = File.read!(file)
12+
ast = Code.string_to_quoted!(file, columns: true)
13+
14+
{_ast, %{vars: vars}} =
15+
Macro.traverse(
16+
ast,
17+
%{vars: [], symbols: %{}, sym_ranges: [], scope: []},
18+
&prewalk/2,
19+
&postwalk/2
20+
)
21+
22+
Enum.find_value(vars, fn %{name: name, sym_range: range, ref_range: ref_range} ->
23+
if position_in_range?(position, ref_range), do: {name, range}, else: nil
24+
end)
25+
end
26+
27+
@spec list_variable_references(String.t(), {integer(), integer()}) :: [{atom(), {Range.t(), Range.t()}}]
28+
def list_variable_references(file, position) do
29+
file = File.read!(file)
30+
ast = Code.string_to_quoted!(file, columns: true)
31+
32+
{_ast, %{vars: vars}} =
33+
Macro.traverse(
34+
ast,
35+
%{vars: [], symbols: %{}, sym_ranges: [], scope: []},
36+
&prewalk/2,
37+
&postwalk/2
38+
)
39+
40+
symbol =
41+
Enum.find_value(vars, fn %{name: name, sym_range: range, ref_range: ref_range} ->
42+
if position_in_range?(position, ref_range), do: {name, range}, else: nil
43+
end)
44+
45+
position =
46+
case symbol do
47+
nil -> position
48+
{_, {line.._, column.._}} -> {line, column}
49+
end
50+
51+
Enum.reduce(vars, [], fn val, acc ->
52+
if position_in_range?(position, val.sym_range) do
53+
[{val.name, val.ref_range} | acc]
54+
else
55+
acc
56+
end
57+
end)
58+
end
59+
60+
# search symbols in function and macro definition args and increase scope
61+
defp prewalk({operation, meta, [args | _]} = ast, acc) when operation in @defs_with_args do
62+
acc = increase_scope_nesting(acc, meta[:line])
63+
acc = find_symbols(args, acc)
64+
{ast, acc}
65+
end
66+
67+
# special case for 'cond', don't search for symbols in left side of 'cond' clause
68+
defp prewalk({:->, meta, _} = ast, %{scope: ["cond" <> _ | _]} = acc) do
69+
acc = increase_scope_nesting(acc, meta[:line])
70+
{ast, acc}
71+
end
72+
73+
# search symbols in a left side of forward arrow clause and increase scope
74+
defp prewalk({:->, meta, [left, _right]} = ast, acc) do
75+
acc = increase_scope_nesting(acc, meta[:line])
76+
acc = find_symbols(left, acc)
77+
{ast, acc}
78+
end
79+
80+
# special case for 'cond'
81+
defp prewalk({:cond, meta, _args} = ast, acc) do
82+
acc = increase_scope_nesting(acc, "cond#{meta[:line]}")
83+
{ast, acc}
84+
end
85+
86+
# increase scope on enter
87+
defp prewalk({operation, meta, _args} = ast, acc) when operation in @scope_breaks do
88+
acc = increase_scope_nesting(acc, meta[:line])
89+
{ast, acc}
90+
end
91+
92+
# special case for 'cond'
93+
defp prewalk({:do, _args} = ast, %{scope: ["cond" <> _ | _]} = acc) do
94+
acc = increase_scope_nesting(acc, "conddo")
95+
{ast, acc}
96+
end
97+
98+
# increase scope on enter 'do/end' block
99+
defp prewalk({operation, _args} = ast, acc) when operation in @blocks do
100+
acc = increase_scope_nesting(acc, operation)
101+
{ast, acc}
102+
end
103+
104+
# search symbols inside left side of a match or <- and fix processig sequence
105+
defp prewalk({operation, meta, [left, right]}, acc) when operation in [:=, :<-, :destructure] do
106+
acc = find_symbols(left, acc)
107+
{{operation, meta, [right, left]}, acc}
108+
end
109+
110+
# exclude attribute macro from variable search
111+
defp prewalk({:@, _, _}, acc) do
112+
{nil, acc}
113+
end
114+
115+
# find variable
116+
defp prewalk({name, meta, nil} = ast, acc) do
117+
range = calculate_range(name, meta[:line], meta[:column])
118+
type = if range in acc.sym_ranges, do: :sym, else: :ref
119+
var = {type, name, range, acc.scope}
120+
121+
acc = collect_var(acc, var)
122+
123+
{ast, acc}
124+
end
125+
126+
defp prewalk(ast, acc), do: {ast, acc}
127+
128+
# decrease scope when exiting it
129+
defp postwalk({operation, _, _} = ast, acc) when operation in @scope_ends do
130+
acc = decrease_scope_nesting(acc)
131+
{ast, acc}
132+
end
133+
134+
# decrease scope when exiting 'do/else' block
135+
defp postwalk({operation, _} = ast, acc) when operation in @blocks do
136+
acc = decrease_scope_nesting(acc)
137+
{ast, acc}
138+
end
139+
140+
defp postwalk(ast, acc), do: {ast, acc}
141+
142+
defp find_symbols(ast, acc) do
143+
{_ast, acc} = Macro.prewalk(ast, acc, &find_symbol/2)
144+
acc
145+
end
146+
147+
defp find_symbol({operation, _, _}, acc) when operation in [:^, :unquote] do
148+
{nil, acc}
149+
end
150+
151+
# exclude right side of 'when' from symbol search
152+
defp find_symbol({:when, _, [left, _right]}, acc) do
153+
{left, acc}
154+
end
155+
156+
defp find_symbol({name, meta, nil} = ast, acc) do
157+
range = calculate_range(name, meta[:line], meta[:column])
158+
acc = Map.update!(acc, :sym_ranges, &[range | &1])
159+
{ast, acc}
160+
end
161+
162+
defp find_symbol(ast, acc), do: {ast, acc}
163+
164+
defp calculate_range(name, line, column) do
165+
length = name |> to_string() |> String.length()
166+
167+
{line..line, column..(column + length - 1)}
168+
end
169+
170+
defp position_in_range?({position_line, position_column}, {range_lines, range_columns}) do
171+
position_line in range_lines and position_column in range_columns
172+
end
173+
174+
defp in_scope?(inner_scope, outer_scope) do
175+
outer = Enum.reverse(outer_scope)
176+
inner = Enum.reverse(inner_scope)
177+
List.starts_with?(inner, outer)
178+
end
179+
180+
defp increase_scope_nesting(acc, identifier) do
181+
Map.update!(acc, :scope, &[to_string(identifier) | &1])
182+
end
183+
184+
defp decrease_scope_nesting(acc) do
185+
Map.update!(acc, :scope, &tl(&1))
186+
end
187+
188+
# add new symbol with scope
189+
defp collect_var(acc, {:sym, name, range, scope}) do
190+
symbol = %{
191+
range: range,
192+
scope: scope
193+
}
194+
195+
update_in(acc, [:symbols, name], fn
196+
nil -> [symbol]
197+
vals -> [symbol | vals]
198+
end)
199+
end
200+
201+
# ignore reference which was not defined yet
202+
defp collect_var(%{symbols: symbols} = acc, {:ref, name, _, _}) when not is_map_key(symbols, name), do: acc
203+
204+
# find symbol for current reference and save sym/ref pair
205+
# remove symbol scopes if reference is from outer scope
206+
defp collect_var(acc, {:ref, name, range, scope}) do
207+
case Enum.split_while(acc.symbols[name], &(not in_scope?(scope, &1.scope))) do
208+
{_, []} ->
209+
acc
210+
211+
{_, symbols_in_scope} ->
212+
var_pair = %{
213+
name: name,
214+
sym_range: hd(symbols_in_scope).range,
215+
ref_range: range
216+
}
217+
218+
acc
219+
|> Map.update!(:vars, &[var_pair | &1])
220+
|> Map.update!(:symbols, &%{&1 | name => symbols_in_scope})
221+
end
222+
end
223+
end

test/next_ls/definition_test.exs

+67
Original file line numberDiff line numberDiff line change
@@ -621,4 +621,71 @@ defmodule NextLS.DefinitionTest do
621621
500
622622
end
623623
end
624+
625+
describe "local variables" do
626+
@describetag root_paths: ["my_proj"]
627+
setup %{tmp_dir: tmp_dir} do
628+
File.mkdir_p!(Path.join(tmp_dir, "my_proj/lib"))
629+
File.write!(Path.join(tmp_dir, "my_proj/mix.exs"), mix_exs())
630+
[cwd: tmp_dir]
631+
end
632+
633+
setup %{cwd: cwd} do
634+
bar = Path.join(cwd, "my_proj/lib/bar.ex")
635+
636+
File.write!(bar, """
637+
defmodule Bar do
638+
@my_attr 1
639+
640+
def run({:ok, alpha} = bravo) do
641+
if @my_attr == 1 do
642+
charlie = "Something: " <> alpha
643+
644+
{:ok, charlie}
645+
else
646+
bravo
647+
end
648+
end
649+
end
650+
""")
651+
652+
[bar: bar]
653+
end
654+
655+
setup :with_lsp
656+
657+
test "go to local variable definition", %{client: client, bar: bar} do
658+
assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}})
659+
assert_request(client, "client/registerCapability", fn _params -> nil end)
660+
assert_notification "$/progress", %{"value" => %{"kind" => "end", "message" => "Finished indexing!"}}
661+
662+
uri = uri(bar)
663+
664+
request(client, %{
665+
method: "textDocument/definition",
666+
id: 4,
667+
jsonrpc: "2.0",
668+
params: %{
669+
position: %{line: 7, character: 12},
670+
textDocument: %{uri: uri}
671+
}
672+
})
673+
674+
assert_result 4,
675+
%{
676+
"range" => %{
677+
"start" => %{
678+
"line" => 5,
679+
"character" => 6
680+
},
681+
"end" => %{
682+
"line" => 5,
683+
"character" => 12
684+
}
685+
},
686+
"uri" => ^uri
687+
},
688+
500
689+
end
690+
end
624691
end

0 commit comments

Comments
 (0)