Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 46 additions & 24 deletions lib/iex/lib/iex/introspection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -149,31 +169,23 @@ 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 """
Prints source code.
"""
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}")
Expand All @@ -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}")
Expand All @@ -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}")
Expand All @@ -212,24 +224,34 @@ 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}
{:error, reason} -> {:error, reason}
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}
{:error, reason} -> {:error, reason}
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}
Expand Down
16 changes: 10 additions & 6 deletions lib/iex/test/iex/helpers_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions lib/mix/lib/mix/tasks/source.ex
Original file line number Diff line number Diff line change
@@ -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 = <<first, _::binary>>] 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
109 changes: 109 additions & 0 deletions lib/mix/test/mix/tasks/source_test.exs
Original file line number Diff line number Diff line change
@@ -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
13 changes: 10 additions & 3 deletions lib/mix/test/test_helper.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: []

Expand All @@ -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"))
Expand Down
Loading