Skip to content

Commit 8f40bf1

Browse files
authored
Add "mix source MODULE" to print or open a given module/function location (#15234)
1 parent 8d69169 commit 8f40bf1

File tree

5 files changed

+259
-33
lines changed

5 files changed

+259
-33
lines changed

lib/iex/lib/iex/introspection.ex

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,29 @@ defmodule IEx.Introspection do
135135
end
136136

137137
def open({file, line}) when is_binary(file) and is_integer(line) do
138+
case open_location(file, line) do
139+
{:ok, result} -> IO.write(IEx.color(:eval_info, result))
140+
{:error, message} -> puts_error(message)
141+
end
142+
143+
dont_display_result()
144+
end
145+
146+
def open(invalid) do
147+
puts_error("Invalid arguments for open helper: #{inspect(invalid)}")
148+
dont_display_result()
149+
end
150+
151+
@doc """
152+
Opens the given file at the given line using the ELIXIR_EDITOR or EDITOR
153+
environment variable.
154+
155+
Returns `{:ok, result}` on success or `{:error, message}` on failure.
156+
"""
157+
def open_location(file, line) when is_binary(file) and is_integer(line) do
138158
cond do
139159
not File.regular?(file) ->
140-
puts_error("Could not open #{inspect(file)}, file is not available.")
160+
{:error, "Could not open #{inspect(file)}, file is not available."}
141161

142162
editor = System.get_env("ELIXIR_EDITOR") || System.get_env("EDITOR") ->
143163
command =
@@ -149,31 +169,23 @@ defmodule IEx.Introspection do
149169
"#{editor} #{inspect(file)}:#{line}"
150170
end
151171

152-
IO.write(IEx.color(:eval_info, :os.cmd(String.to_charlist(command))))
172+
{:ok, :os.cmd(String.to_charlist(command))}
153173

154174
true ->
155-
puts_error(
156-
"Could not open: #{inspect(file)}. " <>
157-
"Please set the ELIXIR_EDITOR or EDITOR environment variables with the " <>
158-
"command line invocation of your favorite EDITOR."
159-
)
175+
{:error,
176+
"Could not open: #{inspect(file)}. " <>
177+
"Please set the ELIXIR_EDITOR or EDITOR environment variables with the " <>
178+
"command line invocation of your favorite EDITOR."}
160179
end
161-
162-
dont_display_result()
163-
end
164-
165-
def open(invalid) do
166-
puts_error("Invalid arguments for open helper: #{inspect(invalid)}")
167-
dont_display_result()
168180
end
169181

170182
@doc """
171183
Prints source code.
172184
"""
173185
def source(module) when is_atom(module) do
174186
case source_location(module) do
175-
{:ok, {file, _line}} ->
176-
IO.puts(File.read!(file))
187+
{:ok, {file, line}} ->
188+
print_source(file, line)
177189

178190
{:error, reason} ->
179191
puts_error("Could not show source for #{inspect(module)}, #{reason}")
@@ -184,8 +196,8 @@ defmodule IEx.Introspection do
184196

185197
def source({module, function}) when is_atom(module) and is_atom(function) do
186198
case source_location({module, function}) do
187-
{:ok, {file, _line}} ->
188-
IO.puts(File.read!(file))
199+
{:ok, {file, line}} ->
200+
print_source(file, line)
189201

190202
{:error, reason} ->
191203
puts_error("Could not show source for #{inspect(module)}.#{function}, #{reason}")
@@ -197,8 +209,8 @@ defmodule IEx.Introspection do
197209
def source({module, function, arity})
198210
when is_atom(module) and is_atom(function) and is_integer(arity) do
199211
case source_location({module, function, arity}) do
200-
{:ok, {file, _line}} ->
201-
IO.puts(File.read!(file))
212+
{:ok, {file, line}} ->
213+
print_source(file, line)
202214

203215
{:error, reason} ->
204216
puts_error("Could not show source for #{inspect(module)}.#{function}/#{arity}, #{reason}")
@@ -212,24 +224,34 @@ defmodule IEx.Introspection do
212224
dont_display_result()
213225
end
214226

215-
defp source_location(module) when is_atom(module) do
227+
defp print_source(file, line) do
228+
IO.puts(Path.relative_to_cwd(file) <> ":" <> Integer.to_string(line))
229+
end
230+
231+
@doc """
232+
Returns the source location for the given module, {module, function},
233+
or {module, function, arity}.
234+
235+
Returns `{:ok, {file, line}}` or `{:error, reason}`.
236+
"""
237+
def source_location(module) when is_atom(module) do
216238
case source_mfa(module, :__info__, 1) do
217239
{source, nil, _} -> {:ok, {source, 1}}
218240
{_, tuple, _} -> {:ok, tuple}
219241
{:error, reason} -> {:error, reason}
220242
end
221243
end
222244

223-
defp source_location({module, function}) when is_atom(module) and is_atom(function) do
245+
def source_location({module, function}) when is_atom(module) and is_atom(function) do
224246
case source_mfa(module, function, :*) do
225247
{_, _, nil} -> {:error, "function/macro is not available"}
226248
{_, _, tuple} -> {:ok, tuple}
227249
{:error, reason} -> {:error, reason}
228250
end
229251
end
230252

231-
defp source_location({module, function, arity})
232-
when is_atom(module) and is_atom(function) and is_integer(arity) do
253+
def source_location({module, function, arity})
254+
when is_atom(module) and is_atom(function) and is_integer(arity) do
233255
case source_mfa(module, function, arity) do
234256
{_, _, nil} -> {:error, "function/macro is not available"}
235257
{_, _, tuple} -> {:ok, tuple}

lib/iex/test/iex/helpers_test.exs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -317,17 +317,21 @@ defmodule IEx.HelpersTest do
317317

318318
describe "source" do
319319
@describetag :requires_source
320+
@example_module_source "test/test_helper.exs"
320321

321-
test "prints source for Elixir module" do
322-
assert capture_iex("source(HelperExampleModule)") =~ "defmodule HelperExampleModule"
322+
test "prints source location for Elixir module" do
323+
assert capture_iex("source(HelperExampleModule)") =~
324+
~r/#{@example_module_source}:\d+$/
323325
end
324326

325-
test "prints source for module.function" do
326-
assert capture_iex("source(HelperExampleModule.fun)") =~ "defmodule HelperExampleModule"
327+
test "prints source location for module.function" do
328+
assert capture_iex("source(HelperExampleModule.fun)") =~
329+
~r/#{@example_module_source}:\d+$/
327330
end
328331

329-
test "prints source for module.function/arity" do
330-
assert capture_iex("source(HelperExampleModule.fun/1)") =~ "defmodule HelperExampleModule"
332+
test "prints source location for module.function/arity" do
333+
assert capture_iex("source(HelperExampleModule.fun/1)") =~
334+
~r/#{@example_module_source}:\d+$/
331335
end
332336

333337
test "errors if module is not available" do

lib/mix/lib/mix/tasks/source.ex

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: 2026 The Elixir Team
3+
4+
defmodule Mix.Tasks.Source do
5+
use Mix.Task
6+
7+
@shortdoc "Prints source location for modules and functions"
8+
9+
@moduledoc """
10+
Prints source file location for modules and functions.
11+
12+
## Examples
13+
14+
$ mix source MODULE - prints the source location for the given module
15+
$ mix source MODULE.FUN - prints the source location for the given module+function
16+
$ mix source MODULE.FUN/ARITY - prints the source location for the given module+function+arity
17+
18+
## Command line options
19+
20+
* `--open`, `-o` - opens the source file in your editor instead of printing the location.
21+
Requires the `ELIXIR_EDITOR` or `EDITOR` environment variable to be set.
22+
23+
"""
24+
25+
@compile {:no_warn_undefined, IEx.Introspection}
26+
27+
@switches [open: :boolean]
28+
@aliases [o: :open]
29+
30+
@impl true
31+
def run(argv) do
32+
{opts, args} = OptionParser.parse!(argv, strict: @switches, aliases: @aliases)
33+
34+
case args do
35+
[module = <<first, _::binary>>] when first in ?A..?Z or first == ?: ->
36+
loadpaths!()
37+
38+
decomposition =
39+
module
40+
|> Code.string_to_quoted!()
41+
|> IEx.Introspection.decompose(__ENV__)
42+
43+
case decomposition do
44+
:error ->
45+
Mix.raise("Invalid expression: #{module}")
46+
47+
_ ->
48+
case IEx.Introspection.source_location(decomposition) do
49+
{:ok, {file, line}} ->
50+
if opts[:open] do
51+
case IEx.Introspection.open_location(file, line) do
52+
{:ok, result} -> IO.write(result)
53+
{:error, message} -> Mix.raise(message)
54+
end
55+
else
56+
Mix.shell().info("#{Path.relative_to_cwd(file)}:#{line}")
57+
end
58+
59+
{:error, reason} ->
60+
Mix.raise("Could not find source for #{module}, #{reason}")
61+
end
62+
end
63+
64+
_ ->
65+
Mix.raise(
66+
"Unexpected arguments, expected \"mix source MODULE\" or \"mix source MODULE.FUN\""
67+
)
68+
end
69+
end
70+
71+
# Loadpaths without checks because modules may be defined in deps.
72+
defp loadpaths! do
73+
args = [
74+
"--no-elixir-version-check",
75+
"--no-deps-check",
76+
"--no-archives-check",
77+
"--no-listeners"
78+
]
79+
80+
Mix.Task.run("loadpaths", args)
81+
Mix.Task.reenable("loadpaths")
82+
Mix.Task.reenable("deps.loadpaths")
83+
end
84+
end
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: 2021 The Elixir Team
3+
4+
Code.require_file("../../test_helper.exs", __DIR__)
5+
6+
defmodule Mix.Tasks.SourceTest do
7+
use MixTest.Case
8+
9+
import ExUnit.CaptureIO
10+
11+
@moduletag :requires_source
12+
@editor System.get_env("ELIXIR_EDITOR")
13+
14+
test "source MODULE", context do
15+
in_tmp(context.test, fn ->
16+
Mix.Tasks.Source.run(["Enum"])
17+
assert_received {:mix_shell, :info, [location]}
18+
assert location =~ ~r"lib/elixir/lib/enum\.ex:\d+"
19+
end)
20+
end
21+
22+
@tag :require_ast
23+
test "source MODULE.FUN", context do
24+
in_tmp(context.test, fn ->
25+
Mix.Tasks.Source.run(["Enum.map"])
26+
assert_received {:mix_shell, :info, [location]}
27+
assert location =~ ~r"lib/elixir/lib/enum\.ex:\d+"
28+
end)
29+
end
30+
31+
@tag :require_ast
32+
test "source MODULE.FUN/ARITY", context do
33+
in_tmp(context.test, fn ->
34+
Mix.Tasks.Source.run(["Enum.map/2"])
35+
assert_received {:mix_shell, :info, [location]}
36+
assert location =~ ~r"lib/elixir/lib/enum\.ex:\d+"
37+
end)
38+
end
39+
40+
test "source NESTED MODULE", context do
41+
in_tmp(context.test, fn ->
42+
Mix.Tasks.Source.run(["IO.ANSI"])
43+
assert_received {:mix_shell, :info, [location]}
44+
assert location =~ ~r"lib/elixir/lib/io/ansi\.ex:\d+"
45+
end)
46+
end
47+
48+
test "source Erlang MODULE", context do
49+
in_tmp(context.test, fn ->
50+
Mix.Tasks.Source.run([":math"])
51+
assert_received {:mix_shell, :info, [location]}
52+
assert location =~ ~r"math\.erl:\d+"
53+
end)
54+
end
55+
56+
test "source ERROR" do
57+
assert_raise Mix.Error, "Invalid expression: Foo.bar(~s[baz])", fn ->
58+
Mix.Tasks.Source.run(["Foo.bar(~s[baz])"])
59+
end
60+
end
61+
62+
test "source unavailable module" do
63+
assert_raise Mix.Error, ~r/Could not find source/, fn ->
64+
Mix.Tasks.Source.run(["DoesNotExist"])
65+
end
66+
end
67+
68+
test "source --open opens __FILE__ and __LINE__", context do
69+
System.put_env("ELIXIR_EDITOR", "echo __LINE__:__FILE__")
70+
71+
in_tmp(context.test, fn ->
72+
output =
73+
capture_io(fn ->
74+
Mix.Tasks.Source.run(["--open", "Enum"])
75+
end)
76+
77+
assert output =~ ~r"\d+:.*lib/elixir/lib/enum\.ex"
78+
end)
79+
after
80+
if @editor,
81+
do: System.put_env("ELIXIR_EDITOR", @editor),
82+
else: System.delete_env("ELIXIR_EDITOR")
83+
end
84+
85+
test "source --open without editor" do
86+
System.delete_env("ELIXIR_EDITOR")
87+
System.delete_env("EDITOR")
88+
89+
assert_raise Mix.Error, ~r/ELIXIR_EDITOR/, fn ->
90+
Mix.Tasks.Source.run(["--open", "Enum"])
91+
end
92+
after
93+
if @editor,
94+
do: System.put_env("ELIXIR_EDITOR", @editor),
95+
else: System.delete_env("ELIXIR_EDITOR")
96+
end
97+
98+
test "bad arguments" do
99+
message = ~r/Unexpected arguments/
100+
101+
assert_raise Mix.Error, message, fn ->
102+
Mix.Tasks.Source.run(["foo", "bar"])
103+
end
104+
105+
assert_raise Mix.Error, message, fn ->
106+
Mix.Tasks.Source.run([])
107+
end
108+
end
109+
end

lib/mix/test/test_helper.exs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ git_exclude =
3636
{line_exclude, line_include} =
3737
if line = System.get_env("LINE"), do: {[:test], [line: line]}, else: {[], []}
3838

39-
cover_exclude =
39+
deterministic_exclude =
4040
if :deterministic in :compile.env_compiler_options() do
41-
[:cover]
41+
[:cover, :requires_source]
4242
else
4343
[]
4444
end
@@ -52,7 +52,13 @@ re_import_exclude =
5252
end
5353

5454
Code.require_file("../../elixir/scripts/cover_record.exs", __DIR__)
55-
CoverageRecorder.maybe_record("mix")
55+
56+
cover_exclude =
57+
if CoverageRecorder.maybe_record("mix") do
58+
[:require_ast]
59+
else
60+
[]
61+
end
5662

5763
maybe_seed_opt = if seed = System.get_env("SEED"), do: [seed: String.to_integer(seed)], else: []
5864

@@ -61,6 +67,7 @@ ex_unit_opts =
6167
trace: !!System.get_env("TRACE"),
6268
exclude:
6369
epmd_exclude ++
70+
deterministic_exclude ++
6471
os_exclude ++ git_exclude ++ line_exclude ++ cover_exclude ++ re_import_exclude,
6572
include: line_include,
6673
assert_receive_timeout: String.to_integer(System.get_env("ELIXIR_ASSERT_TIMEOUT", "300"))

0 commit comments

Comments
 (0)