diff --git a/lib/elixir_sense/core/compiler.ex b/lib/elixir_sense/core/compiler.ex index 5bb62c7c..073374fd 100644 --- a/lib/elixir_sense/core/compiler.ex +++ b/lib/elixir_sense/core/compiler.ex @@ -948,6 +948,27 @@ defmodule ElixirSense.Core.Compiler do # Macro handling + defp expand_macro( + meta, + Kernel, + :use, + [target_module | _opts] = args, + callback, + state, + env + ) do + {expanded_module, state, env} = expand(target_module, state, env) + + state = + if is_atom(expanded_module) do + State.add_use(expanded_module, state, env) + else + state + end + + expand_macro_callback(meta, Kernel, :use, args, callback, state, env) + end + defp expand_macro( meta, Kernel, @@ -2153,8 +2174,10 @@ defmodule ElixirSense.Core.Compiler do # If expanding the macro fails, we just give up. _kind, _payload -> # Logger.warning(Exception.format(kind, payload, __STACKTRACE__)) + uses = state.uses # look for cursor in args {_ast, state, _env} = expand(args, state, env) + state = %{state | uses: uses} {{{:., meta, [module, fun]}, meta, args}, state, env} else diff --git a/lib/elixir_sense/core/compiler/state.ex b/lib/elixir_sense/core/compiler/state.ex index 3d091afd..790518fe 100644 --- a/lib/elixir_sense/core/compiler/state.ex +++ b/lib/elixir_sense/core/compiler/state.ex @@ -48,6 +48,7 @@ defmodule ElixirSense.Core.Compiler.State do attributes: list(list(ElixirSense.Core.State.AttributeInfo.t())), scope_attributes: list(list(atom)), behaviours: %{optional(module) => [module]}, + uses: %{optional(module) => [module]}, specs: specs_t, types: types_t, mods_funs_to_positions: mods_funs_to_positions_t, @@ -88,6 +89,7 @@ defmodule ElixirSense.Core.Compiler.State do defstruct attributes: [[]], scope_attributes: [[]], behaviours: %{}, + uses: %{}, specs: %{}, types: %{}, mods_funs_to_positions: %{}, @@ -1050,6 +1052,12 @@ defmodule ElixirSense.Core.Compiler.State do def add_behaviour(_module, %__MODULE__{} = state, env), do: {nil, state, env} + def add_use(module, %__MODULE__{} = state, env) when is_atom(module) and not is_nil(module) do + update_in(state.uses[env.module], &Enum.uniq([module | &1 || []])) + end + + def add_use(_module, %__MODULE__{} = state, _env), do: state + def register_doc(%__MODULE__{} = state, env, :moduledoc, doc_arg) do current_module = env.module doc_arg_formatted = format_doc_arg(doc_arg) diff --git a/lib/elixir_sense/core/metadata.ex b/lib/elixir_sense/core/metadata.ex index 72192db8..97ca4356 100644 --- a/lib/elixir_sense/core/metadata.ex +++ b/lib/elixir_sense/core/metadata.ex @@ -25,6 +25,7 @@ defmodule ElixirSense.Core.Metadata do specs: ElixirSense.Core.Compiler.State.specs_t(), structs: ElixirSense.Core.Compiler.State.structs_t(), records: ElixirSense.Core.Compiler.State.records_t(), + uses: %{optional(module) => [module]}, error: nil | term, first_alias_positions: map(), moduledoc_positions: map() @@ -41,6 +42,7 @@ defmodule ElixirSense.Core.Metadata do specs: %{}, structs: %{}, records: %{}, + uses: %{}, error: nil, first_alias_positions: %{}, moduledoc_positions: %{} @@ -62,6 +64,7 @@ defmodule ElixirSense.Core.Metadata do specs: acc.specs, structs: acc.structs, records: acc.records, + uses: acc.uses, mods_funs_to_positions: acc.mods_funs_to_positions, lines_to_env: acc.lines_to_env, vars_info_per_scope_id: acc.vars_info_per_scope_id, diff --git a/lib/elixir_sense/core/parser.ex b/lib/elixir_sense/core/parser.ex index aaade4eb..462b6bf4 100644 --- a/lib/elixir_sense/core/parser.ex +++ b/lib/elixir_sense/core/parser.ex @@ -190,6 +190,7 @@ defmodule ElixirSense.Core.Parser do specs: acc.specs, structs: acc.structs, records: acc.records, + uses: acc.uses, mods_funs_to_positions: acc.mods_funs_to_positions, cursor_env: acc.cursor_env, closest_env: acc.closest_env, diff --git a/lib/elixir_sense/providers/definition/locator.ex b/lib/elixir_sense/providers/definition/locator.ex index be2a3fe8..36ef6935 100644 --- a/lib/elixir_sense/providers/definition/locator.ex +++ b/lib/elixir_sense/providers/definition/locator.ex @@ -250,19 +250,47 @@ defmodule ElixirSense.Providers.Definition.Locator do case fn_definition do nil -> - Location.find_mod_fun_source(mod, fun, call_arity) + # Try to find function in __using__ macro before giving up and pointing to the macro use + case find_function_in_module_using_macro(mod, fun, metadata) do + %Location{file: file} = location when not is_nil(file) -> + location + + _ -> + Location.find_mod_fun_source(mod, fun, call_arity) + end %ModFunInfo{} = info -> {{line, column}, {end_line, end_column}} = Location.info_to_range(info) - %Location{ - file: nil, - type: ModFunInfo.get_category(info), - line: line, - column: column, - end_line: end_line, - end_column: end_column - } + if ModFunInfo.get_category(info) in [:function, :macro] do + # First try to use find_function_in_module_using_macro for external modules + # This is needed when the function is defined in a different file via __using__ + case find_function_in_module_using_macro(mod, fun, metadata) do + %Location{file: file} = location when not is_nil(file) -> + location + + _ -> + find_function_in_using_macro( + metadata, + env, + line, + column, + end_line, + end_column, + fun, + info + ) + end + else + %Location{ + file: nil, + type: ModFunInfo.get_category(info), + line: line, + column: column, + end_line: end_line, + end_column: end_column + } + end end {mod, fun, true, :type} -> @@ -313,4 +341,129 @@ defmodule ElixirSense.Providers.Definition.Locator do _ -> nil end end + + defp resolve_use_module({:__aliases__, _, parts}, env) do + [head | tail] = parts + + case Keyword.fetch(env.aliases, Module.concat(Elixir, head)) do + {:ok, aliased_mod} -> + Module.concat([aliased_mod | tail]) + + :error -> + Module.concat(parts) + end + end + + defp resolve_use_module(atom, _env) when is_atom(atom), do: atom + defp resolve_use_module(_, _), do: nil + + defp find_function_in_module_using_macro(mod, fun, metadata) do + if Map.has_key?(metadata.mods_funs_to_positions, {mod, nil, nil}) do + # Module is in the current source - use metadata.uses + used_modules = Map.get(metadata.uses, mod, []) + + Enum.find_value(used_modules, fn used_module -> + search_in_using_macro(used_module, fun) + end) + else + # Module is external - read the file contents and parse it + with file when not is_nil(file) <- get_module_source_file(mod), + {:ok, content} <- File.read(file) do + external_metadata = Parser.parse_string(content, false, false, nil) + used_modules = Map.get(external_metadata.uses, mod, []) + + Enum.find_value(used_modules, fn used_module -> + search_in_using_macro(used_module, fun) + end) + else + _ -> nil + end + end + end + + defp get_module_source_file(mod) do + compile_info = mod.__info__(:compile) + source = Keyword.get(compile_info, :source) + if source, do: to_string(source) + catch + _, _ -> nil + end + + defp search_in_using_macro(module, fun) do + case Location.find_mod_fun_source(module, :__using__, :any) do + %Location{file: file, line: using_line, column: using_col} when not is_nil(file) -> + find_function_def_in_using_macro_source(file, using_line, using_col, fun) + + _ -> + nil + end + end + + defp find_function_def_in_using_macro_source(file, using_line, using_col, fun) do + content = File.read!(file) + {_, suffix} = Source.split_at(content, using_line, using_col) + regex = ~r/def\s+(#{Regex.escape(Atom.to_string(fun))}\b)/ + + case Regex.run(regex, suffix, return: :index) do + [_entire_match, {fun_offset, _fun_len}] -> + {line_offset, col_offset} = calculate_offset(suffix, fun_offset) + target_line = using_line + line_offset + target_column = if line_offset == 0, do: using_col + col_offset, else: 1 + col_offset + + %Location{ + file: file, + type: :function, + line: target_line, + column: target_column, + end_line: target_line, + end_column: target_column + } + + nil -> + nil + end + end + + defp calculate_offset(suffix, fun_offset) do + suffix + |> String.slice(0, fun_offset) + |> Source.split_lines() + |> then(fn lines -> {length(lines) - 1, String.length(List.last(lines) || "")} end) + end + + defp fallback_location(line, column, end_line, end_column, info) do + %Location{ + file: nil, + type: if(info, do: ModFunInfo.get_category(info), else: :function), + line: line, + column: column, + end_line: end_line, + end_column: end_column + } + end + + defp find_function_in_using_macro(metadata, env, line, column, end_line, end_column, fun, info) do + # This might be a function defined via `use` + # We need to find the `use` statement and the module being used + source_line = Source.split_lines(metadata.source) |> Enum.at(line - 1) + + used_module = + case Code.string_to_quoted(source_line) do + {:ok, {:use, _, [module_ast | _]}} -> + resolve_use_module(module_ast, env) + + _ -> + nil + end + + case used_module && Location.find_mod_fun_source(used_module, :__using__, :any) do + %Location{file: file, line: using_line, column: using_col} = using_location + when not is_nil(file) -> + find_function_def_in_using_macro_source(file, using_line, using_col, fun) || + using_location + + _ -> + fallback_location(line, column, end_line, end_column, info) + end + end end diff --git a/test/elixir_sense/providers/definition/locator_test.exs b/test/elixir_sense/providers/definition/locator_test.exs new file mode 100644 index 00000000..90c90a4f --- /dev/null +++ b/test/elixir_sense/providers/definition/locator_test.exs @@ -0,0 +1,252 @@ +defmodule ElixirSense.Providers.Definition.LocatorTest.MyBehaviour do + defmacro __using__(_opts) do + quote do + def my_function(), do: :ok + end + end +end + +defmodule ElixirSense.Providers.Definition.LocatorTest.ModUsingBehaviour do + use ElixirSense.Providers.Definition.LocatorTest.MyBehaviour +end + +defmodule ElixirSense.Providers.Definition.LocatorTest do + use ExUnit.Case, async: true + alias ElixirSense.Providers.Definition.Locator + + test "finds definition of function defined in __using__ macro" do + code = """ + defmodule MyModule do + use ElixirSense.Providers.Definition.LocatorTest.MyBehaviour + + def test do + my_function() + end + end + """ + + {line, column} = {5, 5} + + location = Locator.definition(code, line, column) + + assert location != nil + assert location.type == :function + assert location.line == 4 + assert location.column == 11 + end + + test "finds definition of function defined in __using__ macro via another module" do + code = """ + defmodule MyModule do + def test do + ElixirSense.Providers.Definition.LocatorTest.ModUsingBehaviour.my_function() + end + end + """ + + {line, column} = {3, 70} + + location = Locator.definition(code, line, column) + + assert location != nil + assert location.type == :function + assert location.line == 4 + assert location.column == 11 + end + + test "finds definition of function defined in __using__ macro from external file" do + code = """ + defmodule MyModule do + use ElixirSenseExample.UsingMacroExample + + def test do + using_macro_function() + end + end + """ + + {line, column} = {5, 5} + + location = Locator.definition(code, line, column) + + assert location != nil + assert location.type == :function + assert location.file =~ "using_macro_example.ex" + assert location.line == 4 + assert location.column == 11 + end + + test "finds definition of function defined in __using__ macro via module from external file" do + code = """ + defmodule MyModule do + def test do + ElixirSenseExample.ModuleUsingMacroExample.using_macro_function() + end + end + """ + + {line, column} = {3, 49} + + location = Locator.definition(code, line, column) + + assert location != nil + assert location.type == :function + assert location.file =~ "using_macro_example.ex" + assert location.line == 4 + assert location.column == 11 + end + + test "finds definition when using Kernel.use qualified call" do + code = """ + defmodule MyModule do + Kernel.use(ElixirSense.Providers.Definition.LocatorTest.MyBehaviour) + + def test do + my_function() + end + end + """ + + {line, column} = {5, 5} + + location = Locator.definition(code, line, column) + + assert location != nil + assert location.type == :function + assert location.line == 4 + assert location.column == 11 + end + + test "finds definition when using module alias" do + code = """ + defmodule MyModule do + alias ElixirSense.Providers.Definition.LocatorTest.MyBehaviour + + use MyBehaviour + + def test do + my_function() + end + end + """ + + {line, column} = {7, 5} + + location = Locator.definition(code, line, column) + + assert location != nil + assert location.type == :function + assert location.line == 4 + assert location.column == 11 + end + + test "finds definition when using module with custom alias" do + code = """ + defmodule MyModule do + alias ElixirSense.Providers.Definition.LocatorTest.MyBehaviour, as: MyB + + use MyB + + def test do + my_function() + end + end + """ + + {line, column} = {7, 5} + + location = Locator.definition(code, line, column) + + assert location != nil + assert location.type == :function + assert location.line == 4 + assert location.column == 11 + end + + test "no false positive when local function named use exists" do + code = """ + defmodule MyModule do + defp use(_module), do: nil + + use ElixirSense.Providers.Definition.LocatorTest.MyBehaviour + + def test do + use(SomeModule) + my_function() + end + end + """ + + {line, column} = {8, 5} + + location = Locator.definition(code, line, column) + + assert location != nil + assert location.type == :function + # should find the function from __using__ macro, not be confused by local use/1 + assert location.line == 4 + assert location.column == 11 + end + + describe "modules in external file" do + test "finds definition via external module using Kernel.use qualified call" do + code = """ + defmodule MyModule do + def test do + ElixirSenseExample.ModuleUsingKernelUse.using_macro_function() + end + end + """ + + {line, column} = {3, 45} + + location = Locator.definition(code, line, column) + + assert location != nil + assert location.type == :function + assert location.file =~ "using_macro_example.ex" + assert location.line == 4 + assert location.column == 11 + end + + test "finds definition via external module using aliased module" do + code = """ + defmodule MyModule do + def test do + ElixirSenseExample.ModuleUsingAlias.using_macro_function() + end + end + """ + + {line, column} = {3, 42} + + location = Locator.definition(code, line, column) + + assert location != nil + assert location.type == :function + assert location.file =~ "using_macro_example.ex" + assert location.line == 4 + assert location.column == 11 + end + + test "finds definition via external module that has other local functions" do + code = """ + defmodule MyModule do + def test do + ElixirSenseExample.ModuleWithLocalUse.using_macro_function() + end + end + """ + + {line, column} = {3, 44} + + location = Locator.definition(code, line, column) + + assert location != nil + assert location.type == :function + assert location.file =~ "using_macro_example.ex" + assert location.line == 4 + assert location.column == 11 + end + end +end diff --git a/test/support/using_macro_example.ex b/test/support/using_macro_example.ex new file mode 100644 index 00000000..593b8ecc --- /dev/null +++ b/test/support/using_macro_example.ex @@ -0,0 +1,37 @@ +defmodule ElixirSenseExample.UsingMacroExample do + defmacro __using__(_opts) do + quote do + def using_macro_function(), do: :ok + end + end +end + +defmodule ElixirSenseExample.ModuleUsingMacroExample do + use ElixirSenseExample.UsingMacroExample +end + +# Module using Kernel.use qualified call (tests reviewer concern #1) +defmodule ElixirSenseExample.ModuleUsingKernelUse do + Kernel.use(ElixirSenseExample.UsingMacroExample) +end + +# Module using aliased module (tests reviewer concern #2) +defmodule ElixirSenseExample.ModuleUsingAlias do + alias ElixirSenseExample.UsingMacroExample, as: MyMacro + + use MyMacro +end + +# Module with local function named use (tests reviewer concern #3) +# This tests that a local function named `use` doesn't confuse use tracking +defmodule ElixirSenseExample.ModuleWithLocalUse do + # This local function exists but won't shadow the Kernel.use macro + # The test verifies that proper AST expansion correctly identifies Kernel.use + defp my_use(_module), do: nil + + use ElixirSenseExample.UsingMacroExample + + def call_local_use do + my_use(SomeModule) + end +end