From 8cdbd941cad25407110eb9a7aabcff43c7043303 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Fri, 19 Jul 2024 12:20:01 +0200 Subject: [PATCH] improve graceful handling of defs with unquote fragments --- lib/elixir_sense/core/compiler.ex | 66 +++--- .../core/metadata_builder_test.exs | 191 +++++++++++------- 2 files changed, 151 insertions(+), 106 deletions(-) diff --git a/lib/elixir_sense/core/compiler.ex b/lib/elixir_sense/core/compiler.ex index 2a6773b7..61957f9a 100644 --- a/lib/elixir_sense/core/compiler.ex +++ b/lib/elixir_sense/core/compiler.ex @@ -1621,56 +1621,52 @@ defmodule ElixirSense.Core.Compiler do ) when module != nil and def_kind in [:def, :defp, :defmacro, :defmacrop, :defguard, :defguardp] do - # dbg(call) - # dbg(expr) - # dbg(def_kind) - %{vars: vars, unused: unused} = state - # unquoted_call = :elixir_quote.has_unquotes(call) - # unquoted_expr = :elixir_quote.has_unquotes(expr) - # TODO expand the call and expression. - # TODO store mod_fun_to_pos line = Keyword.fetch!(meta, :line) unquoted_call = __MODULE__.Quote.has_unquotes(call) unquoted_expr = __MODULE__.Quote.has_unquotes(expr) + has_unquotes = unquoted_call or unquoted_expr - {call, expr} = - if unquoted_expr or unquoted_call do - {call, expr} = __MODULE__.Quote.escape({call, expr}, :none, true) - - try do - # TODO binding? - {{call, expr}, _} = Code.eval_quoted({call, expr}, [], env) - {call, expr} - rescue - _ -> raise "unable to eval #{inspect({call, expr})}" - end - else - {call, expr} - end + # if there are unquote fragments in either call or body elixir escapes both and evaluates + # if unquoted_expr or unquoted_call, do: __MODULE__.Quote.escape({call, expr}, :none, true) + # instead we try to expand the call and body ignoring the unquotes + # {name_and_args, guards} = __MODULE__.Utils.extract_guards(call) + # elixir raises here if def is invalid, we try to continue with unknown + # especially, we return unknown for calls with unquote fragments {name, _meta_1, args} = case name_and_args do {n, m, a} when is_atom(n) and is_atom(a) -> {n, m, []} {n, m, a} when is_atom(n) and is_list(a) -> {n, m, a} - _ -> raise "invalid_def #{inspect(name_and_args)}" + {n, m, a} when is_atom(a) -> {:__unknown__, m, []} + {n, m, a} when is_list(a) -> {:__unknown__, m, a} + _ -> {:__unknown__, [], []} end arity = length(args) # based on :elixir_def.env_for_expansion state = - %{ - state - | vars: {%{}, false}, - unused: 0, - caller: def_kind in [:defmacro, :defmacrop, :defguard, :defguardp] - } - |> new_func_vars_scope + unless has_unquotes do + # module vars are not accessible in def body + %{ + state + | vars: {%{}, false}, + unused: 0, + caller: def_kind in [:defmacro, :defmacrop, :defguard, :defguardp] + } + |> new_func_vars_scope() + else + # make module variables accessible if there are unquote fragments in def body + %{ + state + | caller: def_kind in [:defmacro, :defmacrop, :defguard, :defguardp] + } + end env_for_expand = %{env | function: {name, arity}} @@ -1716,7 +1712,15 @@ defmodule ElixirSense.Core.Compiler do %{state | vars: vars, unused: unused, caller: false} |> maybe_move_vars_to_outer_scope |> remove_vars_scope - |> remove_func_vars_scope + + state = + unless has_unquotes do + # restore module vars + remove_func_vars_scope(state) + else + # no need to do anything + state + end # result of def expansion is fa tuple {{name, arity}, state, env} diff --git a/test/elixir_sense/core/metadata_builder_test.exs b/test/elixir_sense/core/metadata_builder_test.exs index 6339bbb6..1ec02208 100644 --- a/test/elixir_sense/core/metadata_builder_test.exs +++ b/test/elixir_sense/core/metadata_builder_test.exs @@ -578,25 +578,23 @@ defmodule ElixirSense.Core.MetadataBuilderTest do assert [] = state |> get_line_vars(3) end - if Version.match?(System.version(), ">= 1.17.0-dev") and @compiler do - test "for bitstring" do - state = - """ - for <> do - record_env() - end + test "for bitstring" do + state = + """ + for <> do record_env() - """ - |> string_to_state + end + record_env() + """ + |> string_to_state - assert Map.keys(state.lines_to_env[2].versioned_vars) == [{:b, nil}, {:g, nil}, {:r, nil}] + assert Map.keys(state.lines_to_env[2].versioned_vars) == [{:b, nil}, {:g, nil}, {:r, nil}] - assert [ - %VarInfo{name: :b, positions: [{1, 19}]}, - %VarInfo{name: :g, positions: [{1, 13}]}, - %VarInfo{name: :r, positions: [{1, 7}]} - ] = state |> get_line_vars(2) - end + assert [ + %VarInfo{name: :b, positions: [{1, 19}]}, + %VarInfo{name: :g, positions: [{1, 13}]}, + %VarInfo{name: :r, positions: [{1, 7}]} + ] = state |> get_line_vars(2) end test "for assignment" do @@ -917,6 +915,32 @@ defmodule ElixirSense.Core.MetadataBuilderTest do ] = state |> get_line_vars(8) end + test "in unquote fragment" do + state = + """ + defmodule MyModuleWithFuns do + kv = [foo: 1, bar: 2] |> IO.inspect + Enum.each(kv, fn {k, v} -> + @spec unquote(k)() :: unquote(v) + def unquote(k)() do + unquote(v) + record_env() + end + end) + end + """ + |> string_to_state + + assert Map.keys(state.lines_to_env[7].versioned_vars) == [{:k, nil}, {:kv, nil}, {:v, nil}] + + # TODO we are not tracking usages in typespec + assert [ + %VarInfo{name: :k, positions: [{3, 21}]}, + %VarInfo{name: :kv, positions: [{2, 3}, {3, 13}]}, + %VarInfo{name: :v, positions: [{3, 24}, {6, 15}]} + ] = state |> get_line_vars(7) + end + test "in capture" do state = """ @@ -2514,7 +2538,7 @@ defmodule ElixirSense.Core.MetadataBuilderTest do """ |> string_to_state - vars = state |> get_line_vars(6) + # vars = state |> get_line_vars(6) # TODO wtf # assert %VarInfo{ @@ -5656,52 +5680,44 @@ defmodule ElixirSense.Core.MetadataBuilderTest do } = state.mods_funs_to_positions end - if @expand_eval do - test "registers defs with unquote fragments" do - state = - """ - defmodule MyModuleWithFuns do - def unquote(:foo)(), do: :ok - def bar(), do: unquote(:ok) - def baz(unquote(:abc)), do: unquote(:abc) - end - """ - |> string_to_state + test "registers defs with unquote fragments in body" do + state = + """ + defmodule MyModuleWithFuns do + kv = [foo: 1] + Enum.each(kv, fn {k, v} -> + def foo(), do: unquote(v) + end) + end + """ + |> string_to_state - assert %{ - {MyModuleWithFuns, :foo, 0} => %ModFunInfo{ - params: [[]] - }, - {MyModuleWithFuns, :bar, 0} => %ModFunInfo{ - params: [[]] - }, - {MyModuleWithFuns, :baz, 1} => %ModFunInfo{ - params: [[:abc]] - } - } = state.mods_funs_to_positions - end + assert %{ + {MyModuleWithFuns, :foo, 0} => %ModFunInfo{ + params: [[]] + } + } = state.mods_funs_to_positions + end - test "registers defs with unquote fragments with binding" do - state = - """ - defmodule MyModuleWithFuns do - kv = [foo: 1, bar: 2] |> IO.inspect - Enum.each(kv, fn {k, v} -> - def unquote(k)(), do: unquote(v) - end) - end - """ - |> string_to_state + test "registers unknown for defs with unquote fragments in call" do + state = + """ + defmodule MyModuleWithFuns do + kv = [foo: 1, bar: 2] + Enum.each(kv, fn {k, v} -> + def unquote(k)(), do: 123 + end) + end + """ + |> string_to_state - assert %{ - {MyModuleWithFuns, :foo, 0} => %ModFunInfo{ - params: [[]] - }, - {MyModuleWithFuns, :bar, 0} => %ModFunInfo{ - params: [[]] - } - } = state.mods_funs_to_positions - end + assert Map.keys(state.mods_funs_to_positions) == [ + {MyModuleWithFuns, :__info__, 1}, + {MyModuleWithFuns, :__unknown__, 0}, + {MyModuleWithFuns, :module_info, 0}, + {MyModuleWithFuns, :module_info, 1}, + {MyModuleWithFuns, nil, nil} + ] end test "registers builtin functions for protocols" do @@ -6338,10 +6354,6 @@ defmodule ElixirSense.Core.MetadataBuilderTest do end describe "calls" do - defp sort_calls(calls) do - calls |> Enum.map(fn {k, v} -> {k, Enum.sort(v)} end) |> Map.new() - end - test "registers calls with __MODULE__" do state = """ @@ -7204,6 +7216,46 @@ defmodule ElixirSense.Core.MetadataBuilderTest do } = state.types end + test "registers types with unquote fragments in body" do + state = + """ + defmodule My do + kv = [foo: 1] + Enum.each(kv, fn {k, v} -> + @type foo :: unquote(v) + end) + end + """ + |> string_to_state + + assert %{ + {My, :foo, 0} => %ElixirSense.Core.State.TypeInfo{ + args: [[]], + kind: :type, + name: :foo, + positions: [{4, 5}], + end_positions: [{4, 28}], + generated: [false], + specs: ["@type foo() :: unquote(v())"] + } + } = state.types + end + + test "skips types with unquote fragments in call" do + state = + """ + defmodule My do + kv = [foo: 1] + Enum.each(kv, fn {k, v} -> + @type unquote(k)() :: 123 + end) + end + """ + |> string_to_state + + assert %{} == state.types + end + test "protocol exports type t" do state = """ @@ -8160,17 +8212,6 @@ defmodule ElixirSense.Core.MetadataBuilderTest do |> MetadataBuilder.build() end - defp get_scope_vars(state, line) do - case state.lines_to_env[line] do - nil -> - [] - - env -> - state.vars_info_per_scope_id[env.scope_id] - end - |> Enum.sort() - end - defp get_line_vars(state, line) do case state.lines_to_env[line] do nil ->