Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add options for enabling/disabling Gradient type-checking for specific apps/files #124

Closed
wants to merge 9 commits into from
9 changes: 9 additions & 0 deletions examples/simple_umbrella_app/apps/app_a/lib/app_a_helper.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
defmodule AppAHelper do
@moduledoc """
Documentation for `AppAHelper`.
"""

def help do
:here_you_go
end
end
9 changes: 9 additions & 0 deletions examples/simple_umbrella_app/apps/app_b/lib/app_b_helper.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
defmodule AppBHelper do
@moduledoc """
Documentation for `AppBHelper`.
"""

def help do
:here_you_go
end
end
6 changes: 1 addition & 5 deletions examples/simple_umbrella_app/apps/app_b/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,6 @@ defmodule AppB.MixProject do

# Run "mix help deps" to learn about dependencies.
defp deps do
[{:app_a, in_umbrella: true}
# {:dep_from_hexpm, "~> 0.3.0"},
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"},
# {:sibling_app_in_umbrella, in_umbrella: true}
]
[{:app_a, in_umbrella: true}]
end
end
2 changes: 1 addition & 1 deletion examples/simple_umbrella_app/mix.lock
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
%{
"gradualizer": {:git, "https://github.com/josefs/Gradualizer.git", "9e629ade733780113973fe672a51d9650ed0cd86", [ref: "9e629ad"]},
"gradualizer": {:git, "https://github.com/josefs/Gradualizer.git", "ba5481cf1c208a0c29d15e5ab241b5af58dde28d", [ref: "ba5481c"]},
}
18 changes: 14 additions & 4 deletions lib/gradient.ex
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,7 @@ defmodule Gradient do

defp maybe_specify_forms(forms, opts) do
unless opts[:no_specify] do
forms
|> put_source_path(opts)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in #138 this line is not necessary because before maybe_specify_forms/2 is called, we already call put_source_path.

|> AstSpecifier.specify()
AstSpecifier.specify(forms)
else
forms
end
Expand All @@ -181,7 +179,7 @@ defmodule Gradient do
{:attribute, anno, :file, {path, line}} = hd(forms)

[
{:attribute, anno, :file, {String.to_charlist(app_path) ++ '/' ++ path, line}}
{:attribute, anno, :file, {maybe_prepend_app_path(app_path, path), line}}
| tl(forms)
]
end
Expand All @@ -191,6 +189,18 @@ defmodule Gradient do
end
end

defp maybe_prepend_app_path(app_path, path) do
unless is_absolute_path(path) do
String.to_charlist(app_path) ++ '/' ++ path
else
path
end
end

# Check if the specified path (either binary or charlist) is an absolute path
defp is_absolute_path(path) when is_list(path), do: path |> to_string() |> is_absolute_path()
defp is_absolute_path(path) when is_binary(path), do: path == Path.absname(path)

defp get_first_forms(forms) do
forms
|> List.first()
Expand Down
16 changes: 13 additions & 3 deletions lib/gradient/elixir_file_utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,24 @@ defmodule Gradient.ElixirFileUtils do

@spec load_tokens([:erl_parse.abstract_form()]) :: Types.tokens()
def load_tokens(forms) do
with [{:attribute, _, :file, {path, _}} | _] <- forms,
path <- to_string(path),
case forms do
[{:attribute, _, :file, {path, _}} | _] ->
load_tokens_at_path(path)

_ ->
IO.puts("Error finding file attribute from forms: #{inspect(forms)}")
[]
end
end

defp load_tokens_at_path(path) do
with path <- to_string(path),
{:ok, code} <- File.read(path),
{:ok, tokens} <- :elixir.string_to_tokens(String.to_charlist(code), 1, 1, path, []) do
tokens
else
error ->
IO.puts("Cannot load tokens: #{inspect(error)}")
IO.puts("Error loading tokens from file at path #{inspect(path)}: #{inspect(error)}")
[]
end
end
Expand Down
14 changes: 14 additions & 0 deletions lib/mix/tasks/clean_examples.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
defmodule Mix.Tasks.CleanExamples do
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had some test failures while running some of the tests that were fixed by removing the compiled test files so they could be re-compiled from scratch. I'm adding this mix task, along with an alias to call it before mix test, to automate the process so that others won't experience the same issues.

@moduledoc """
Mix task for removing compiled files under test/examples/_build and
test/examples/erlang/_build. Useful for getting a clean build for tests.
"""

use Mix.Task

@impl Mix.Task
def run(_args) do
File.rm_rf!("test/examples/_build")
File.rm_rf!("test/examples/erlang/_build")
end
end
141 changes: 137 additions & 4 deletions lib/mix/tasks/gradient.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule Mix.Tasks.Gradient do
## Command-line options

* `--no-compile` - do not compile even if needed
* `--no-ex-check` - do not perform checks specyfic for Elixir
* `--no-ex-check` - do not perform checks specific for Elixir
(from ElixirChecker module)
* `--no-gradualizer-check` - do not perform the Gradualizer checks
* `--no-specify` - do not specify missing lines in AST what can
Expand All @@ -20,6 +20,7 @@ defmodule Mix.Tasks.Gradient do
* `--infer` - infer type information from literals and other language
constructs,
* `--verbose` - show what Gradualizer is doing
* `--print-filenames` - print the name of every file being analyzed (mainly useful for tests)
* `--no-fancy` - do not use fancy error messages
* `--fmt-location none` - do not display location for easier comparison
* `--fmt-location brief` - display location for machine processing
Expand All @@ -40,6 +41,8 @@ defmodule Mix.Tasks.Gradient do

use Mix.Task

alias Gradient.ElixirFileUtils

@options [
# skip phases options
no_compile: :boolean,
Expand All @@ -53,6 +56,7 @@ defmodule Mix.Tasks.Gradient do
stop_on_first_error: :boolean,
infer: :boolean,
verbose: :boolean,
print_filenames: :boolean,
# formatter options
no_fancy: :boolean,
fmt_location: :string,
Expand All @@ -73,14 +77,18 @@ defmodule Mix.Tasks.Gradient do

options = Enum.reduce(options, [], &prepare_option/2)

# Compile the project before the analysis
maybe_compile_project(options)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved this earlier in the pipeline to avoid a specific issue - when you run mix clean before mix gradient inside examples/simple_umbrella_app, you would get an error about AppA.hello() being undefined.

When I dug in, the reason is that in Gradualizer, gradualizer_db:init/1 is called before AppA is compiled, so Gradualizer doesn't have any of the sources from this project loaded.

It turns out that gradualizer_db:init/1 is called when we call Application.ensure_all_started(:gradualizer), so the solution is simply to move the compilation step above, so it happens before we start Gradualizer.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks!

# Load dependencies
maybe_load_deps(options)
# Start Gradualizer application
Application.ensure_all_started(:gradualizer)
# Compile the project before the analysis
maybe_compile_project(options)
# Get paths to files
files = get_paths(user_paths)
files = get_paths(user_paths) |> filter_enabled_paths()

if options[:print_filenames] do
print_filenames(files)
end

IO.puts("Typechecking files...")

Expand All @@ -100,6 +108,36 @@ defmodule Mix.Tasks.Gradient do
:ok
end

defp print_filenames(files) do
IO.puts("Files to check:")

cwd = File.cwd!() <> "/"
# Range for slicing off cwd from the beginning of a string
cwd_range = String.length(cwd)..-1

Enum.each(files, fn {app, filenames} ->
# Print app name
case app do
"apps/" <> appname -> IO.puts("Files in app #{appname}:")
# May be nil for non-umbrella apps
_ -> :noop
end

# Print actual filenames
for filename <- filenames do
# Convert charlist to string
filename = to_string(filename)
# If the filename starts with the cwd, don't print the cwd
filename =
if String.starts_with?(filename, cwd),
do: String.slice(filename, cwd_range),
else: filename

IO.puts(filename)
end
end)
end

defp execute(stream, opts) do
crash_on_error? = Keyword.get(opts, :crash_on_error, false)

Expand Down Expand Up @@ -272,4 +310,99 @@ defmodule Mix.Tasks.Gradient do
defp compile_path(app_name) do
Mix.Project.build_path() <> "/lib/" <> to_string(app_name) <> "/ebin"
end

@spec filter_enabled_paths(map()) :: map()
def filter_enabled_paths(apps_paths) do
apps_paths
|> Enum.map(fn {app_name, app_files} ->
{app_name, filter_paths_for_app(app_name, app_files)}
end)
|> Map.new()
end

defp filter_paths_for_app(app_name, app_files) do
config = gradient_config_for_app(app_name)

enabled? = Keyword.get(config, :enabled, true)
file_overrides_enabled? = Keyword.get(config, :file_overrides, enabled?)

if file_overrides_enabled? do
magic_comment =
if enabled?, do: "# gradient:disable-for-file", else: "# gradient:enable-for-file"

filter_files_with_magic_comment(app_name, app_files, magic_comment, not enabled?)
else
if enabled?, do: app_files, else: []
end
end

defp gradient_config_for_app(app) do
app_config = mix_config_for_app(app)[:gradient] || []

if Mix.Project.umbrella?() do
# Merge in config from umbrella app
umbrella_config = Mix.Project.config()[:gradient] || []
Keyword.merge(umbrella_config, app_config)
else
app_config
end
end

defp path_for_app(nil), do: ""
defp path_for_app(app_name), do: app_name <> "/"

defp mix_config_for_app(nil), do: Mix.Project.config()

defp mix_config_for_app(app) do
# Read the file looking for a "defmodule" to get the module name of the app's mixfile
mixfile_module_str =
File.stream!(path_for_app(app) <> "mix.exs")
|> Enum.find(&String.starts_with?(&1, "defmodule"))
|> (fn a -> Regex.run(~r/defmodule ([\w.]+)/, a) end).()
|> Enum.at(1)

mixfile_module = String.to_atom("Elixir." <> mixfile_module_str)

# return the module's config
mixfile_module.project()
end

defp filter_files_with_magic_comment(app_name, beam_files, comment, should_include?) do
app_path = path_for_app(app_name) |> Path.expand()
deps_path = Path.expand(mix_config_for_app(app_name)[:deps_path] || "deps")

Enum.filter(beam_files, fn beam_path ->
# Given the path to a .beam file, find out the .ex source file path
ex_path =
ex_filename_from_beam(beam_path)
# Turn the relative path into absolute
|> Path.expand(app_path)

# Filter out any paths representing code generated by deps
is_dep = String.starts_with?(ex_path, deps_path)

# Filter out the files with the magic comment
has_magic_comment? =
File.stream!(ex_path)
|> Enum.any?(fn line -> String.trim(line) == comment end)

# Negate has_magic_comment? if should_include? is false
has_magic_comment? =
if should_include?, do: has_magic_comment?, else: not has_magic_comment?

has_magic_comment? and not is_dep
end)
end

defp ex_filename_from_beam(beam_path) do
case ElixirFileUtils.get_forms(beam_path) do
{:ok, [[{:attribute, _, :file, {filename, _}} | _] | _]} ->
# Convert the *.beam compiled filename to its corresponding *.ex source file
# (it's a charlist initially so we pipe it through to_string)
filename |> to_string()

error ->
raise "Error resolving .ex filename from compiled .beam filename #{inspect(beam_path)}: #{inspect(error)}"
end
end
end
5 changes: 4 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ defmodule Gradient.MixProject do
end

def aliases do
[]
[
# Before testing, remove compiled test/examples files for a clean build
test: ["clean_examples", "test"]
]
end

def escript() do
Expand Down
Loading