diff --git a/lib/iex/lib/iex/introspection.ex b/lib/iex/lib/iex/introspection.ex index af0806a89d8..6c3aad09dc7 100644 --- a/lib/iex/lib/iex/introspection.ex +++ b/lib/iex/lib/iex/introspection.ex @@ -135,9 +135,29 @@ defmodule IEx.Introspection do end def open({file, line}) when is_binary(file) and is_integer(line) do + case open_location(file, line) do + {:ok, result} -> IO.write(IEx.color(:eval_info, result)) + {:error, message} -> puts_error(message) + end + + dont_display_result() + end + + def open(invalid) do + puts_error("Invalid arguments for open helper: #{inspect(invalid)}") + dont_display_result() + end + + @doc """ + Opens the given file at the given line using the ELIXIR_EDITOR or EDITOR + environment variable. + + Returns `{:ok, result}` on success or `{:error, message}` on failure. + """ + def open_location(file, line) when is_binary(file) and is_integer(line) do cond do not File.regular?(file) -> - puts_error("Could not open #{inspect(file)}, file is not available.") + {:error, "Could not open #{inspect(file)}, file is not available."} editor = System.get_env("ELIXIR_EDITOR") || System.get_env("EDITOR") -> command = @@ -149,22 +169,14 @@ defmodule IEx.Introspection do "#{editor} #{inspect(file)}:#{line}" end - IO.write(IEx.color(:eval_info, :os.cmd(String.to_charlist(command)))) + {:ok, :os.cmd(String.to_charlist(command))} true -> - puts_error( - "Could not open: #{inspect(file)}. " <> - "Please set the ELIXIR_EDITOR or EDITOR environment variables with the " <> - "command line invocation of your favorite EDITOR." - ) + {:error, + "Could not open: #{inspect(file)}. " <> + "Please set the ELIXIR_EDITOR or EDITOR environment variables with the " <> + "command line invocation of your favorite EDITOR."} end - - dont_display_result() - end - - def open(invalid) do - puts_error("Invalid arguments for open helper: #{inspect(invalid)}") - dont_display_result() end @doc """ @@ -172,8 +184,8 @@ defmodule IEx.Introspection do """ def source(module) when is_atom(module) do case source_location(module) do - {:ok, {file, _line}} -> - IO.puts(File.read!(file)) + {:ok, {file, line}} -> + print_source(file, line) {:error, reason} -> puts_error("Could not show source for #{inspect(module)}, #{reason}") @@ -184,8 +196,8 @@ defmodule IEx.Introspection do def source({module, function}) when is_atom(module) and is_atom(function) do case source_location({module, function}) do - {:ok, {file, _line}} -> - IO.puts(File.read!(file)) + {:ok, {file, line}} -> + print_source(file, line) {:error, reason} -> puts_error("Could not show source for #{inspect(module)}.#{function}, #{reason}") @@ -197,8 +209,8 @@ defmodule IEx.Introspection do def source({module, function, arity}) when is_atom(module) and is_atom(function) and is_integer(arity) do case source_location({module, function, arity}) do - {:ok, {file, _line}} -> - IO.puts(File.read!(file)) + {:ok, {file, line}} -> + print_source(file, line) {:error, reason} -> puts_error("Could not show source for #{inspect(module)}.#{function}/#{arity}, #{reason}") @@ -212,7 +224,17 @@ defmodule IEx.Introspection do dont_display_result() end - defp source_location(module) when is_atom(module) do + defp print_source(file, line) do + IO.puts(Path.relative_to_cwd(file) <> ":" <> Integer.to_string(line)) + end + + @doc """ + Returns the source location for the given module, {module, function}, + or {module, function, arity}. + + Returns `{:ok, {file, line}}` or `{:error, reason}`. + """ + def source_location(module) when is_atom(module) do case source_mfa(module, :__info__, 1) do {source, nil, _} -> {:ok, {source, 1}} {_, tuple, _} -> {:ok, tuple} @@ -220,7 +242,7 @@ defmodule IEx.Introspection do end end - defp source_location({module, function}) when is_atom(module) and is_atom(function) do + def source_location({module, function}) when is_atom(module) and is_atom(function) do case source_mfa(module, function, :*) do {_, _, nil} -> {:error, "function/macro is not available"} {_, _, tuple} -> {:ok, tuple} @@ -228,8 +250,8 @@ defmodule IEx.Introspection do end end - defp source_location({module, function, arity}) - when is_atom(module) and is_atom(function) and is_integer(arity) do + def source_location({module, function, arity}) + when is_atom(module) and is_atom(function) and is_integer(arity) do case source_mfa(module, function, arity) do {_, _, nil} -> {:error, "function/macro is not available"} {_, _, tuple} -> {:ok, tuple} diff --git a/lib/iex/test/iex/helpers_test.exs b/lib/iex/test/iex/helpers_test.exs index e285f9ab186..06baaf67637 100644 --- a/lib/iex/test/iex/helpers_test.exs +++ b/lib/iex/test/iex/helpers_test.exs @@ -317,17 +317,21 @@ defmodule IEx.HelpersTest do describe "source" do @describetag :requires_source + @example_module_source "test/test_helper.exs" - test "prints source for Elixir module" do - assert capture_iex("source(HelperExampleModule)") =~ "defmodule HelperExampleModule" + test "prints source location for Elixir module" do + assert capture_iex("source(HelperExampleModule)") =~ + ~r/#{@example_module_source}:\d+$/ end - test "prints source for module.function" do - assert capture_iex("source(HelperExampleModule.fun)") =~ "defmodule HelperExampleModule" + test "prints source location for module.function" do + assert capture_iex("source(HelperExampleModule.fun)") =~ + ~r/#{@example_module_source}:\d+$/ end - test "prints source for module.function/arity" do - assert capture_iex("source(HelperExampleModule.fun/1)") =~ "defmodule HelperExampleModule" + test "prints source location for module.function/arity" do + assert capture_iex("source(HelperExampleModule.fun/1)") =~ + ~r/#{@example_module_source}:\d+$/ end test "errors if module is not available" do diff --git a/lib/mix/lib/mix/tasks/source.ex b/lib/mix/lib/mix/tasks/source.ex new file mode 100644 index 00000000000..49c4d9b05d5 --- /dev/null +++ b/lib/mix/lib/mix/tasks/source.ex @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 The Elixir Team + +defmodule Mix.Tasks.Source do + use Mix.Task + + @shortdoc "Prints source location for modules and functions" + + @moduledoc """ + Prints source file location for modules and functions. + + ## Examples + + $ mix source MODULE - prints the source location for the given module + $ mix source MODULE.FUN - prints the source location for the given module+function + $ mix source MODULE.FUN/ARITY - prints the source location for the given module+function+arity + + ## Command line options + + * `--open`, `-o` - opens the source file in your editor instead of printing the location. + Requires the `ELIXIR_EDITOR` or `EDITOR` environment variable to be set. + + """ + + @compile {:no_warn_undefined, IEx.Introspection} + + @switches [open: :boolean] + @aliases [o: :open] + + @impl true + def run(argv) do + {opts, args} = OptionParser.parse!(argv, strict: @switches, aliases: @aliases) + + case args do + [module = <>] when first in ?A..?Z or first == ?: -> + loadpaths!() + + decomposition = + module + |> Code.string_to_quoted!() + |> IEx.Introspection.decompose(__ENV__) + + case decomposition do + :error -> + Mix.raise("Invalid expression: #{module}") + + _ -> + case IEx.Introspection.source_location(decomposition) do + {:ok, {file, line}} -> + if opts[:open] do + case IEx.Introspection.open_location(file, line) do + {:ok, result} -> IO.write(result) + {:error, message} -> Mix.raise(message) + end + else + Mix.shell().info("#{Path.relative_to_cwd(file)}:#{line}") + end + + {:error, reason} -> + Mix.raise("Could not find source for #{module}, #{reason}") + end + end + + _ -> + Mix.raise( + "Unexpected arguments, expected \"mix source MODULE\" or \"mix source MODULE.FUN\"" + ) + end + end + + # Loadpaths without checks because modules may be defined in deps. + defp loadpaths! do + args = [ + "--no-elixir-version-check", + "--no-deps-check", + "--no-archives-check", + "--no-listeners" + ] + + Mix.Task.run("loadpaths", args) + Mix.Task.reenable("loadpaths") + Mix.Task.reenable("deps.loadpaths") + end +end diff --git a/lib/mix/test/mix/tasks/source_test.exs b/lib/mix/test/mix/tasks/source_test.exs new file mode 100644 index 00000000000..39457686bce --- /dev/null +++ b/lib/mix/test/mix/tasks/source_test.exs @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2021 The Elixir Team + +Code.require_file("../../test_helper.exs", __DIR__) + +defmodule Mix.Tasks.SourceTest do + use MixTest.Case + + import ExUnit.CaptureIO + + @moduletag :requires_source + @editor System.get_env("ELIXIR_EDITOR") + + test "source MODULE", context do + in_tmp(context.test, fn -> + Mix.Tasks.Source.run(["Enum"]) + assert_received {:mix_shell, :info, [location]} + assert location =~ ~r"lib/elixir/lib/enum\.ex:\d+" + end) + end + + @tag :require_ast + test "source MODULE.FUN", context do + in_tmp(context.test, fn -> + Mix.Tasks.Source.run(["Enum.map"]) + assert_received {:mix_shell, :info, [location]} + assert location =~ ~r"lib/elixir/lib/enum\.ex:\d+" + end) + end + + @tag :require_ast + test "source MODULE.FUN/ARITY", context do + in_tmp(context.test, fn -> + Mix.Tasks.Source.run(["Enum.map/2"]) + assert_received {:mix_shell, :info, [location]} + assert location =~ ~r"lib/elixir/lib/enum\.ex:\d+" + end) + end + + test "source NESTED MODULE", context do + in_tmp(context.test, fn -> + Mix.Tasks.Source.run(["IO.ANSI"]) + assert_received {:mix_shell, :info, [location]} + assert location =~ ~r"lib/elixir/lib/io/ansi\.ex:\d+" + end) + end + + test "source Erlang MODULE", context do + in_tmp(context.test, fn -> + Mix.Tasks.Source.run([":math"]) + assert_received {:mix_shell, :info, [location]} + assert location =~ ~r"math\.erl:\d+" + end) + end + + test "source ERROR" do + assert_raise Mix.Error, "Invalid expression: Foo.bar(~s[baz])", fn -> + Mix.Tasks.Source.run(["Foo.bar(~s[baz])"]) + end + end + + test "source unavailable module" do + assert_raise Mix.Error, ~r/Could not find source/, fn -> + Mix.Tasks.Source.run(["DoesNotExist"]) + end + end + + test "source --open opens __FILE__ and __LINE__", context do + System.put_env("ELIXIR_EDITOR", "echo __LINE__:__FILE__") + + in_tmp(context.test, fn -> + output = + capture_io(fn -> + Mix.Tasks.Source.run(["--open", "Enum"]) + end) + + assert output =~ ~r"\d+:.*lib/elixir/lib/enum\.ex" + end) + after + if @editor, + do: System.put_env("ELIXIR_EDITOR", @editor), + else: System.delete_env("ELIXIR_EDITOR") + end + + test "source --open without editor" do + System.delete_env("ELIXIR_EDITOR") + System.delete_env("EDITOR") + + assert_raise Mix.Error, ~r/ELIXIR_EDITOR/, fn -> + Mix.Tasks.Source.run(["--open", "Enum"]) + end + after + if @editor, + do: System.put_env("ELIXIR_EDITOR", @editor), + else: System.delete_env("ELIXIR_EDITOR") + end + + test "bad arguments" do + message = ~r/Unexpected arguments/ + + assert_raise Mix.Error, message, fn -> + Mix.Tasks.Source.run(["foo", "bar"]) + end + + assert_raise Mix.Error, message, fn -> + Mix.Tasks.Source.run([]) + end + end +end diff --git a/lib/mix/test/test_helper.exs b/lib/mix/test/test_helper.exs index 143a11b6012..faba7d07f7d 100644 --- a/lib/mix/test/test_helper.exs +++ b/lib/mix/test/test_helper.exs @@ -36,9 +36,9 @@ git_exclude = {line_exclude, line_include} = if line = System.get_env("LINE"), do: {[:test], [line: line]}, else: {[], []} -cover_exclude = +deterministic_exclude = if :deterministic in :compile.env_compiler_options() do - [:cover] + [:cover, :requires_source] else [] end @@ -52,7 +52,13 @@ re_import_exclude = end Code.require_file("../../elixir/scripts/cover_record.exs", __DIR__) -CoverageRecorder.maybe_record("mix") + +cover_exclude = + if CoverageRecorder.maybe_record("mix") do + [:require_ast] + else + [] + end maybe_seed_opt = if seed = System.get_env("SEED"), do: [seed: String.to_integer(seed)], else: [] @@ -61,6 +67,7 @@ ex_unit_opts = trace: !!System.get_env("TRACE"), exclude: epmd_exclude ++ + deterministic_exclude ++ os_exclude ++ git_exclude ++ line_exclude ++ cover_exclude ++ re_import_exclude, include: line_include, assert_receive_timeout: String.to_integer(System.get_env("ELIXIR_ASSERT_TIMEOUT", "300"))