Skip to content

Commit c40bce3

Browse files
committed
Add mix source to fetch or open up a given module/function
1 parent 8d69169 commit c40bce3

File tree

3 files changed

+243
-18
lines changed

3 files changed

+243
-18
lines changed

lib/iex/lib/iex/introspection.ex

Lines changed: 36 additions & 18 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,22 +169,14 @@ 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 """
@@ -212,24 +224,30 @@ defmodule IEx.Introspection do
212224
dont_display_result()
213225
end
214226

215-
defp source_location(module) when is_atom(module) do
227+
@doc """
228+
Returns the source location for the given module, {module, function},
229+
or {module, function, arity}.
230+
231+
Returns `{:ok, {file, line}}` or `{:error, reason}`.
232+
"""
233+
def source_location(module) when is_atom(module) do
216234
case source_mfa(module, :__info__, 1) do
217235
{source, nil, _} -> {:ok, {source, 1}}
218236
{_, tuple, _} -> {:ok, tuple}
219237
{:error, reason} -> {:error, reason}
220238
end
221239
end
222240

223-
defp source_location({module, function}) when is_atom(module) and is_atom(function) do
241+
def source_location({module, function}) when is_atom(module) and is_atom(function) do
224242
case source_mfa(module, function, :*) do
225243
{_, _, nil} -> {:error, "function/macro is not available"}
226244
{_, _, tuple} -> {:ok, tuple}
227245
{:error, reason} -> {:error, reason}
228246
end
229247
end
230248

231-
defp source_location({module, function, arity})
232-
when is_atom(module) and is_atom(function) and is_integer(arity) do
249+
def source_location({module, function, arity})
250+
when is_atom(module) and is_atom(function) and is_integer(arity) do
233251
case source_mfa(module, function, arity) do
234252
{_, _, nil} -> {:error, "function/macro is not available"}
235253
{_, _, tuple} -> {:ok, tuple}

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("#{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: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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+
@editor System.get_env("ELIXIR_EDITOR")
12+
13+
test "source MODULE", context do
14+
in_tmp(context.test, fn ->
15+
Mix.Tasks.Source.run(["Enum"])
16+
assert_received {:mix_shell, :info, [location]}
17+
assert location =~ ~r"lib/elixir/lib/enum\.ex:\d+"
18+
end)
19+
end
20+
21+
test "source MODULE.FUN", context do
22+
in_tmp(context.test, fn ->
23+
Mix.Tasks.Source.run(["Enum.map"])
24+
assert_received {:mix_shell, :info, [location]}
25+
assert location =~ ~r"lib/elixir/lib/enum\.ex:\d+"
26+
end)
27+
end
28+
29+
test "source MODULE.FUN/ARITY", context do
30+
in_tmp(context.test, fn ->
31+
Mix.Tasks.Source.run(["Enum.map/2"])
32+
assert_received {:mix_shell, :info, [location]}
33+
assert location =~ ~r"lib/elixir/lib/enum\.ex:\d+"
34+
end)
35+
end
36+
37+
test "source NESTED MODULE", context do
38+
in_tmp(context.test, fn ->
39+
Mix.Tasks.Source.run(["IO.ANSI"])
40+
assert_received {:mix_shell, :info, [location]}
41+
assert location =~ ~r"lib/elixir/lib/io/ansi\.ex:\d+"
42+
end)
43+
end
44+
45+
test "source Erlang MODULE", context do
46+
in_tmp(context.test, fn ->
47+
Mix.Tasks.Source.run([":math"])
48+
assert_received {:mix_shell, :info, [location]}
49+
assert location =~ ~r"math\.erl:\d+"
50+
end)
51+
end
52+
53+
test "source ERROR" do
54+
assert_raise Mix.Error, "Invalid expression: Foo.bar(~s[baz])", fn ->
55+
Mix.Tasks.Source.run(["Foo.bar(~s[baz])"])
56+
end
57+
end
58+
59+
test "source unavailable module" do
60+
assert_raise Mix.Error, ~r/Could not find source/, fn ->
61+
Mix.Tasks.Source.run(["DoesNotExist"])
62+
end
63+
end
64+
65+
test "source --open opens __FILE__ and __LINE__", context do
66+
System.put_env("ELIXIR_EDITOR", "echo __LINE__:__FILE__")
67+
68+
in_tmp(context.test, fn ->
69+
output =
70+
capture_io(fn ->
71+
Mix.Tasks.Source.run(["--open", "Enum"])
72+
end)
73+
74+
assert output =~ ~r"\d+:.*lib/elixir/lib/enum\.ex"
75+
end)
76+
after
77+
if @editor,
78+
do: System.put_env("ELIXIR_EDITOR", @editor),
79+
else: System.delete_env("ELIXIR_EDITOR")
80+
end
81+
82+
test "source -o opens with shortcut", context do
83+
System.put_env("ELIXIR_EDITOR", "echo __LINE__:__FILE__")
84+
85+
in_tmp(context.test, fn ->
86+
output =
87+
capture_io(fn ->
88+
Mix.Tasks.Source.run(["-o", "Enum.map/2"])
89+
end)
90+
91+
assert output =~ ~r"\d+:.*lib/elixir/lib/enum\.ex"
92+
end)
93+
after
94+
if @editor,
95+
do: System.put_env("ELIXIR_EDITOR", @editor),
96+
else: System.delete_env("ELIXIR_EDITOR")
97+
end
98+
99+
test "source --open without editor" do
100+
System.delete_env("ELIXIR_EDITOR")
101+
System.delete_env("EDITOR")
102+
103+
assert_raise Mix.Error, ~r/ELIXIR_EDITOR/, fn ->
104+
Mix.Tasks.Source.run(["--open", "Enum"])
105+
end
106+
after
107+
if @editor,
108+
do: System.put_env("ELIXIR_EDITOR", @editor),
109+
else: System.delete_env("ELIXIR_EDITOR")
110+
end
111+
112+
test "bad arguments" do
113+
message = ~r/Unexpected arguments/
114+
115+
assert_raise Mix.Error, message, fn ->
116+
Mix.Tasks.Source.run(["foo", "bar"])
117+
end
118+
119+
assert_raise Mix.Error, message, fn ->
120+
Mix.Tasks.Source.run([])
121+
end
122+
end
123+
end

0 commit comments

Comments
 (0)