From c40bce32d8501962fc88c64ebf9ecede8e6bd8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Sat, 4 Apr 2026 20:14:44 +0200 Subject: [PATCH 1/4] Add mix source to fetch or open up a given module/function --- lib/iex/lib/iex/introspection.ex | 54 +++++++---- lib/mix/lib/mix/tasks/source.ex | 84 +++++++++++++++++ lib/mix/test/mix/tasks/source_test.exs | 123 +++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 lib/mix/lib/mix/tasks/source.ex create mode 100644 lib/mix/test/mix/tasks/source_test.exs diff --git a/lib/iex/lib/iex/introspection.ex b/lib/iex/lib/iex/introspection.ex index af0806a89d8..8f8d399bc2a 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 """ @@ -212,7 +224,13 @@ defmodule IEx.Introspection do dont_display_result() end - defp source_location(module) when is_atom(module) do + @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 +238,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 +246,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/mix/lib/mix/tasks/source.ex b/lib/mix/lib/mix/tasks/source.ex new file mode 100644 index 00000000000..114ee493bef --- /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("#{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..a9718ecbb8f --- /dev/null +++ b/lib/mix/test/mix/tasks/source_test.exs @@ -0,0 +1,123 @@ +# 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 + + @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 + + 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 + + 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 -o opens with shortcut", context do + System.put_env("ELIXIR_EDITOR", "echo __LINE__:__FILE__") + + in_tmp(context.test, fn -> + output = + capture_io(fn -> + Mix.Tasks.Source.run(["-o", "Enum.map/2"]) + 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 From 9090fc1f720806affd102dc50a8b18bbbed7fc73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Sat, 4 Apr 2026 20:19:24 +0200 Subject: [PATCH 2/4] Print source location consistently and using relative paths --- lib/iex/lib/iex/introspection.ex | 16 ++++++++++------ lib/iex/test/iex/helpers_test.exs | 16 ++++++++++------ lib/mix/lib/mix/tasks/source.ex | 2 +- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/lib/iex/lib/iex/introspection.ex b/lib/iex/lib/iex/introspection.ex index 8f8d399bc2a..6c3aad09dc7 100644 --- a/lib/iex/lib/iex/introspection.ex +++ b/lib/iex/lib/iex/introspection.ex @@ -184,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}") @@ -196,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}") @@ -209,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}") @@ -224,6 +224,10 @@ defmodule IEx.Introspection do dont_display_result() end + 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}. 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 index 114ee493bef..49c4d9b05d5 100644 --- a/lib/mix/lib/mix/tasks/source.ex +++ b/lib/mix/lib/mix/tasks/source.ex @@ -53,7 +53,7 @@ defmodule Mix.Tasks.Source do {:error, message} -> Mix.raise(message) end else - Mix.shell().info("#{file}:#{line}") + Mix.shell().info("#{Path.relative_to_cwd(file)}:#{line}") end {:error, reason} -> From 07393e609a770d05050bb93188c45eef68c34d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Sat, 4 Apr 2026 20:34:01 +0200 Subject: [PATCH 3/4] Skip modules that require source --- lib/mix/test/mix/tasks/source_test.exs | 1 + lib/mix/test/test_helper.exs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/mix/test/mix/tasks/source_test.exs b/lib/mix/test/mix/tasks/source_test.exs index a9718ecbb8f..c384e2e9d9b 100644 --- a/lib/mix/test/mix/tasks/source_test.exs +++ b/lib/mix/test/mix/tasks/source_test.exs @@ -8,6 +8,7 @@ defmodule Mix.Tasks.SourceTest do import ExUnit.CaptureIO + @moduletag :requires_source @editor System.get_env("ELIXIR_EDITOR") test "source MODULE", context do diff --git a/lib/mix/test/test_helper.exs b/lib/mix/test/test_helper.exs index 143a11b6012..af279f32ded 100644 --- a/lib/mix/test/test_helper.exs +++ b/lib/mix/test/test_helper.exs @@ -38,7 +38,7 @@ git_exclude = cover_exclude = if :deterministic in :compile.env_compiler_options() do - [:cover] + [:cover, :requires_source] else [] end From 611598b0c686f1e50e5100e097d3f35b8453d681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Sat, 4 Apr 2026 20:45:09 +0200 Subject: [PATCH 4/4] Add :require_ast --- lib/mix/test/mix/tasks/source_test.exs | 19 ++----------------- lib/mix/test/test_helper.exs | 11 +++++++++-- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/lib/mix/test/mix/tasks/source_test.exs b/lib/mix/test/mix/tasks/source_test.exs index c384e2e9d9b..39457686bce 100644 --- a/lib/mix/test/mix/tasks/source_test.exs +++ b/lib/mix/test/mix/tasks/source_test.exs @@ -19,6 +19,7 @@ defmodule Mix.Tasks.SourceTest do end) end + @tag :require_ast test "source MODULE.FUN", context do in_tmp(context.test, fn -> Mix.Tasks.Source.run(["Enum.map"]) @@ -27,6 +28,7 @@ defmodule Mix.Tasks.SourceTest do end) end + @tag :require_ast test "source MODULE.FUN/ARITY", context do in_tmp(context.test, fn -> Mix.Tasks.Source.run(["Enum.map/2"]) @@ -80,23 +82,6 @@ defmodule Mix.Tasks.SourceTest do else: System.delete_env("ELIXIR_EDITOR") end - test "source -o opens with shortcut", context do - System.put_env("ELIXIR_EDITOR", "echo __LINE__:__FILE__") - - in_tmp(context.test, fn -> - output = - capture_io(fn -> - Mix.Tasks.Source.run(["-o", "Enum.map/2"]) - 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") diff --git a/lib/mix/test/test_helper.exs b/lib/mix/test/test_helper.exs index af279f32ded..faba7d07f7d 100644 --- a/lib/mix/test/test_helper.exs +++ b/lib/mix/test/test_helper.exs @@ -36,7 +36,7 @@ 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, :requires_source] else @@ -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"))