From 99c569757f10bffeec03679d010856250b5832d2 Mon Sep 17 00:00:00 2001 From: Jan Bergeson Date: Thu, 25 Aug 2022 14:28:51 -0600 Subject: [PATCH 1/7] Add ability to configure individual apps in an umbrella app for enabling/disabling Gradient checks Fix analyzing beam files instead of ex files Working on unit tests for changes Fix unit tests --- .../apps/app_a/lib/app_a_helper.ex | 9 + .../apps/app_b/lib/app_b_helper.ex | 9 + examples/simple_umbrella_app/mix.lock | 2 +- lib/mix/tasks/gradient.ex | 132 +++++++- test/mix/tasks/gradient_test.exs | 295 +++++++++++++++++- 5 files changed, 442 insertions(+), 5 deletions(-) create mode 100644 examples/simple_umbrella_app/apps/app_a/lib/app_a_helper.ex create mode 100644 examples/simple_umbrella_app/apps/app_b/lib/app_b_helper.ex diff --git a/examples/simple_umbrella_app/apps/app_a/lib/app_a_helper.ex b/examples/simple_umbrella_app/apps/app_a/lib/app_a_helper.ex new file mode 100644 index 00000000..585d7bd8 --- /dev/null +++ b/examples/simple_umbrella_app/apps/app_a/lib/app_a_helper.ex @@ -0,0 +1,9 @@ +defmodule AppAHelper do + @moduledoc """ + Documentation for `AppAHelper`. + """ + + def help do + :here_you_go + end +end diff --git a/examples/simple_umbrella_app/apps/app_b/lib/app_b_helper.ex b/examples/simple_umbrella_app/apps/app_b/lib/app_b_helper.ex new file mode 100644 index 00000000..0fae316d --- /dev/null +++ b/examples/simple_umbrella_app/apps/app_b/lib/app_b_helper.ex @@ -0,0 +1,9 @@ +defmodule AppBHelper do + @moduledoc """ + Documentation for `AppBHelper`. + """ + + def help do + :here_you_go + end +end diff --git a/examples/simple_umbrella_app/mix.lock b/examples/simple_umbrella_app/mix.lock index 7f878a50..e9e06476 100644 --- a/examples/simple_umbrella_app/mix.lock +++ b/examples/simple_umbrella_app/mix.lock @@ -1,3 +1,3 @@ %{ - "gradualizer": {:git, "https://github.com/josefs/Gradualizer.git", "9e629ade733780113973fe672a51d9650ed0cd86", [ref: "9e629ad"]}, + "gradualizer": {:git, "https://github.com/josefs/Gradualizer.git", "8b4bf33ffc5596142e690c5aa4f24f2458b9b0fd", [ref: "8b4bf33"]}, } diff --git a/lib/mix/tasks/gradient.ex b/lib/mix/tasks/gradient.ex index 0f7f6d24..7a68ec5a 100644 --- a/lib/mix/tasks/gradient.ex +++ b/lib/mix/tasks/gradient.ex @@ -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 @@ -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 @@ -37,6 +38,8 @@ defmodule Mix.Tasks.Gradient do use Mix.Task + alias Gradient.ElixirFileUtils + @options [ # skip phases options no_compile: :boolean, @@ -50,6 +53,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, @@ -73,7 +77,11 @@ defmodule Mix.Tasks.Gradient do # 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...") @@ -87,6 +95,32 @@ 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) @@ -235,4 +269,98 @@ 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 diff --git a/test/mix/tasks/gradient_test.exs b/test/mix/tasks/gradient_test.exs index d3d325e2..e287d2c8 100644 --- a/test/mix/tasks/gradient_test.exs +++ b/test/mix/tasks/gradient_test.exs @@ -1,5 +1,6 @@ defmodule Mix.Tasks.GradientTest do - use ExUnit.Case + # Async false since these tests use the file system + use ExUnit.Case, async: false import ExUnit.CaptureIO @@ -11,6 +12,23 @@ defmodule Mix.Tasks.GradientTest do @s_wrong_ret_beam Path.join(@build_path, "Elixir.SWrongRet.beam") @s_wrong_ret_ex Path.join([@examples_path, "type", "s_wrong_ret.ex"]) + # Run `mix deps.get` on umbrella project + setup_all do + previous_cwd = File.cwd!() + + try do + # cd to umbrella app dir + File.cd!("examples/simple_umbrella_app") + + # Get deps + {_, 0} = System.cmd("mix", ["deps.get"]) + after + File.cd!(previous_cwd) + end + + :ok + end + setup do Application.put_env(:gradient, :__system_halt__, fn signal -> send(self(), {:system_halt, signal}) @@ -219,7 +237,280 @@ defmodule Mix.Tasks.GradientTest do assert run_task([@examples_path <> "/dependent_modules.ex"]) =~ "No errors found!" end + describe "Umbrella project app filtering:" do + # Templates for dynamically creating variations on the umbrella/subapp + # mix.exs config files at runtime + @umbrella_mix_exs """ + defmodule SimpleUmbrellaApp.MixProject do + use Mix.Project + + def project do + [ + apps_path: "apps", + version: "0.1.0", + start_permanent: Mix.env() == :prod, + deps: deps(), + <%= if not no_config do %> + gradient: [ + <%= if enabled != nil do %> + enabled: <%= enabled %>, + <% end %> + + <%= if overrides != nil do %> + file_overrides: <%= overrides %>, + <% end %> + ] + <% end %> + ] + end + + defp deps do + [{:gradient, path: "../../", override: true}] + end + end + """ + + @subapp_mix_exs """ + defmodule App<%= String.upcase(app) %>.MixProject do + use Mix.Project + + def project do + [ + app: :app_<%= app %>, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.12", + start_permanent: Mix.env() == :prod, + deps: deps(), + <%= if not no_config do %> + gradient: [ + <%= if enabled != nil do %> + enabled: <%= enabled %>, + <% end %> + + <%= if overrides != nil do %> + file_overrides: <%= overrides %>, + <% end %> + ] + <% end %> + ] + end + + <%= if app == "b" do %> + defp deps, do: [{:app_a, in_umbrella: true}] + <% else %> + defp deps, do: [] + <% end %> + end + """ + + @umbrella_app_path "examples/simple_umbrella_app" + @umbrella_mix @umbrella_app_path <> "/mix.exs" + @umbrella_app_a_mix @umbrella_app_path <> "/apps/app_a/mix.exs" + @umbrella_app_b_mix @umbrella_app_path <> "/apps/app_b/mix.exs" + + setup context do + umbrella_mix_contents = File.read!(@umbrella_mix) + app_a_mix_contents = File.read!(@umbrella_app_a_mix) + app_b_mix_contents = File.read!(@umbrella_app_b_mix) + + on_exit(fn -> + # Put back the original file contents at the end of the test + File.write!(@umbrella_mix, umbrella_mix_contents) + File.write!(@umbrella_app_a_mix, app_a_mix_contents) + File.write!(@umbrella_app_b_mix, app_b_mix_contents) + end) + + # Edit files if specified in config + context |> Map.get(:edit_files, %{}) |> edit_files() + + # Get test configs from context + umbrella_mix = Map.get(context, :umbrella, %{}) |> prep_config() + app_a_mix = Map.get(context, :app_a, %{}) |> Map.put(:app, "a") |> prep_config() + app_b_mix = Map.get(context, :app_b, %{}) |> Map.put(:app, "b") |> prep_config() + + # Write new mix.exs file contents for duration of test + File.write!(@umbrella_mix, EEx.eval_string(@umbrella_mix_exs, umbrella_mix)) + File.write!(@umbrella_app_a_mix, EEx.eval_string(@subapp_mix_exs, app_a_mix)) + File.write!(@umbrella_app_b_mix, EEx.eval_string(@subapp_mix_exs, app_b_mix)) + end + + # Merge in default configs, and convert map to keyword list + defp prep_config(mix_exs_config) do + Map.merge( + %{ + no_config: false, + enabled: nil, + overrides: nil + }, + mix_exs_config + ) + |> Keyword.new() + end + + defp edit_files(files) do + for {filename, enabled?} <- files do + filename = @umbrella_app_path <> "/apps/" <> filename + + magic_comment = + if enabled?, do: "# gradient:enable-for-file\n", else: "# gradient:disable-for-file\n" + + file_contents = File.read!(filename) + updated_file_contents = magic_comment <> file_contents + File.write!(filename, updated_file_contents) + + on_exit(fn -> + File.write!(filename, file_contents) + end) + end + end + + # Run the task on the umbrella app, and extract which files it ran on + defp run_task_and_return_files() do + output = run_shell_task("examples/simple_umbrella_app", ["--print-filenames"]) + + # Parse the output and figure out which files it said it ran on + output + |> String.split("\n") + |> Enum.reduce( + %{state: :not_started, apps_files: %{}, curr_app: nil}, + fn line, %{state: state, apps_files: apps_files, curr_app: curr_app} = acc -> + case state do + # Looking for the string "Typechecking files..." to start + :not_started -> + if line == "Files to check:" do + %{acc | state: :started} + else + acc + end + + :started -> + case line do + # Change current app + "Files in app " <> app_name_and_colon -> + # get rid of colon on the end + app_name = String.replace(app_name_and_colon, ":", "") + %{acc | curr_app: app_name} + + # This line signifies the end of the file list + "Typechecking files..." -> + %{acc | state: :finished} + + # Save filename to list for current app + filename -> + file_list_for_app = Map.get(apps_files, curr_app, []) + updated_file_list = file_list_for_app ++ [filename] + updated_apps_files = Map.put(apps_files, curr_app, updated_file_list) + %{acc | apps_files: updated_apps_files} + end + + # Already found all the files, no-op + :finished -> + acc + end + end + ) + # Return just the list of apps/files + |> Map.get(:apps_files) + end + + @tag umbrella: %{no_config: true}, app_a: %{no_config: true}, app_b: %{no_config: true} + test "defaults to enabled when no gradient config in any mix.exs files" do + assert %{ + "app_a" => [ + "_build/dev/lib/app_a/ebin/Elixir.AppA.beam", + "_build/dev/lib/app_a/ebin/Elixir.AppAHelper.beam" + ], + "app_b" => [ + "_build/dev/lib/app_b/ebin/Elixir.AppB.beam", + "_build/dev/lib/app_b/ebin/Elixir.AppBHelper.beam" + ] + } == run_task_and_return_files() + end + + @tag umbrella: %{enabled: true}, app_a: %{no_config: true}, app_b: %{no_config: true} + test "when gradient is enabled for umbrella, checks all files" do + assert %{ + "app_a" => [ + "_build/dev/lib/app_a/ebin/Elixir.AppA.beam", + "_build/dev/lib/app_a/ebin/Elixir.AppAHelper.beam" + ], + "app_b" => [ + "_build/dev/lib/app_b/ebin/Elixir.AppB.beam", + "_build/dev/lib/app_b/ebin/Elixir.AppBHelper.beam" + ] + } == run_task_and_return_files() + end + + @tag umbrella: %{enabled: false}, app_a: %{no_config: true}, app_b: %{no_config: true} + test "when gradient is disabled for umbrella, doesn't check any files" do + assert %{} == run_task_and_return_files() + end + + @tag umbrella: %{enabled: false}, app_a: %{enabled: true} + test "gradient can be enabled for a subapp even if disabled for umbrella" do + assert %{ + "app_a" => [ + "_build/dev/lib/app_a/ebin/Elixir.AppA.beam", + "_build/dev/lib/app_a/ebin/Elixir.AppAHelper.beam" + ] + } == run_task_and_return_files() + end + + @tag umbrella: %{enabled: true}, app_a: %{enabled: false} + test "gradient can be enabled for the umbrella and disabled for a subapp" do + assert %{ + "app_b" => [ + "_build/dev/lib/app_b/ebin/Elixir.AppB.beam", + "_build/dev/lib/app_b/ebin/Elixir.AppBHelper.beam" + ] + } == run_task_and_return_files() + end + + @tag umbrella: %{enabled: true, overrides: true}, + edit_files: %{"app_a/lib/app_a_helper.ex" => false} + test "individual files can be disabled" do + assert %{ + "app_a" => [ + "_build/dev/lib/app_a/ebin/Elixir.AppA.beam" + ], + "app_b" => [ + "_build/dev/lib/app_b/ebin/Elixir.AppB.beam", + "_build/dev/lib/app_b/ebin/Elixir.AppBHelper.beam" + ] + } == run_task_and_return_files() + end + + @tag umbrella: %{enabled: false, overrides: true}, edit_files: %{"app_a/lib/app_a.ex" => true} + test "individual files can be enabled" do + assert %{"app_a" => ["_build/dev/lib/app_a/ebin/Elixir.AppA.beam"]} == + run_task_and_return_files() + end + end + + # Run the task in the current process. Useful for running on a single file. def run_task(args), do: capture_io(fn -> Mix.Tasks.Gradient.run(args) end) - def test_opts(opts), do: ["--no-comile", "--no-deps"] ++ opts + # Run the task in a new process, via the shell. Useful for running on an entire project. + def run_shell_task(dir, args) do + previous_cwd = File.cwd!() + + try do + # cd to the specified dir + File.cd!(dir) + + # Run the task + {output, exit_code} = System.cmd("mix", ["gradient"] ++ args) + assert exit_code == 0, output + + output + after + File.cd!(previous_cwd) + end + end + + def test_opts(opts), do: ["--no-compile", "--no-deps"] ++ opts end From 39dde9eb3b90795ca1427bf0a88e764ffdc0ffe5 Mon Sep 17 00:00:00 2001 From: Jan Bergeson Date: Tue, 1 Nov 2022 10:26:13 -0600 Subject: [PATCH 2/7] Clean compile before testing on umbrella app --- examples/simple_umbrella_app/mix.lock | 2 +- lib/gradient.ex | 1 - lib/mix/tasks/clean_examples.ex | 14 ++++++++++++++ lib/mix/tasks/gradient.ex | 2 +- mix.exs | 5 ++++- test/mix/tasks/gradient_test.exs | 3 +++ 6 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 lib/mix/tasks/clean_examples.ex diff --git a/examples/simple_umbrella_app/mix.lock b/examples/simple_umbrella_app/mix.lock index e9e06476..606eae15 100644 --- a/examples/simple_umbrella_app/mix.lock +++ b/examples/simple_umbrella_app/mix.lock @@ -1,3 +1,3 @@ %{ - "gradualizer": {:git, "https://github.com/josefs/Gradualizer.git", "8b4bf33ffc5596142e690c5aa4f24f2458b9b0fd", [ref: "8b4bf33"]}, + "gradualizer": {:git, "https://github.com/josefs/Gradualizer.git", "ba5481cf1c208a0c29d15e5ab241b5af58dde28d", [ref: "ba5481c"]}, } diff --git a/lib/gradient.ex b/lib/gradient.ex index 2e955f20..be2074b1 100644 --- a/lib/gradient.ex +++ b/lib/gradient.ex @@ -133,7 +133,6 @@ defmodule Gradient do defp maybe_specify_forms(forms, opts) do unless opts[:no_specify] do forms - |> put_source_path(opts) |> AstSpecifier.specify() else forms diff --git a/lib/mix/tasks/clean_examples.ex b/lib/mix/tasks/clean_examples.ex new file mode 100644 index 00000000..d4fd5a04 --- /dev/null +++ b/lib/mix/tasks/clean_examples.ex @@ -0,0 +1,14 @@ +defmodule Mix.Tasks.CleanExamples do + @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 diff --git a/lib/mix/tasks/gradient.ex b/lib/mix/tasks/gradient.ex index 7a68ec5a..fbc7c34b 100644 --- a/lib/mix/tasks/gradient.ex +++ b/lib/mix/tasks/gradient.ex @@ -354,7 +354,7 @@ defmodule Mix.Tasks.Gradient do defp ex_filename_from_beam(beam_path) do case ElixirFileUtils.get_forms(beam_path) do - {:ok, [{:attribute, _, :file, {filename, _}} | _]} -> + {: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() diff --git a/mix.exs b/mix.exs index 14ed4511..8710b6e8 100644 --- a/mix.exs +++ b/mix.exs @@ -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 diff --git a/test/mix/tasks/gradient_test.exs b/test/mix/tasks/gradient_test.exs index e287d2c8..70b9f59f 100644 --- a/test/mix/tasks/gradient_test.exs +++ b/test/mix/tasks/gradient_test.exs @@ -502,6 +502,9 @@ defmodule Mix.Tasks.GradientTest do # cd to the specified dir File.cd!(dir) + # mix clean first + assert {_, 0} = System.cmd("mix", ["clean"]) + # Run the task {output, exit_code} = System.cmd("mix", ["gradient"] ++ args) assert exit_code == 0, output From bd04a489044336d55e05c6baf106ef7cecb546f0 Mon Sep 17 00:00:00 2001 From: Jan Bergeson Date: Tue, 1 Nov 2022 14:03:38 -0600 Subject: [PATCH 3/7] Fix mix task when cleaned first --- examples/simple_umbrella_app/apps/app_b/mix.exs | 6 +----- lib/mix/tasks/gradient.ex | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/examples/simple_umbrella_app/apps/app_b/mix.exs b/examples/simple_umbrella_app/apps/app_b/mix.exs index ae05dcc1..db934baa 100644 --- a/examples/simple_umbrella_app/apps/app_b/mix.exs +++ b/examples/simple_umbrella_app/apps/app_b/mix.exs @@ -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 diff --git a/lib/mix/tasks/gradient.ex b/lib/mix/tasks/gradient.ex index fbc7c34b..b1b1839a 100644 --- a/lib/mix/tasks/gradient.ex +++ b/lib/mix/tasks/gradient.ex @@ -70,12 +70,12 @@ defmodule Mix.Tasks.Gradient do options = Enum.reduce(options, [], &prepare_option/2) + # Compile the project before the analysis + maybe_compile_project(options) # 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) |> filter_enabled_paths() From da07e85a87793c63150596e9a470da71808e1dca Mon Sep 17 00:00:00 2001 From: Jan Bergeson Date: Fri, 4 Nov 2022 15:55:52 -0600 Subject: [PATCH 4/7] Don't prepend app_path for absolute paths. Use File.read! to raise error when reading file fails. --- lib/gradient.ex | 17 ++++++++++++++--- lib/gradient/elixir_file_utils.ex | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/gradient.ex b/lib/gradient.ex index be2074b1..8cb50f7b 100644 --- a/lib/gradient.ex +++ b/lib/gradient.ex @@ -132,8 +132,7 @@ defmodule Gradient do defp maybe_specify_forms(forms, opts) do unless opts[:no_specify] do - forms - |> AstSpecifier.specify() + AstSpecifier.specify(forms) else forms end @@ -158,7 +157,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 @@ -168,6 +167,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() diff --git a/lib/gradient/elixir_file_utils.ex b/lib/gradient/elixir_file_utils.ex index bfbe4c8a..3e329b9c 100644 --- a/lib/gradient/elixir_file_utils.ex +++ b/lib/gradient/elixir_file_utils.ex @@ -109,7 +109,7 @@ defmodule Gradient.ElixirFileUtils do def load_tokens(forms) do with [{:attribute, _, :file, {path, _}} | _] <- forms, path <- to_string(path), - {:ok, code} <- File.read(path), + code <- File.read!(path), {:ok, tokens} <- :elixir.string_to_tokens(String.to_charlist(code), 1, 1, path, []) do tokens else From 2f13126f85f4ee4298dbba6a2c4e7807a5e7e8c4 Mon Sep 17 00:00:00 2001 From: Jan Bergeson Date: Mon, 7 Nov 2022 10:46:22 -0700 Subject: [PATCH 5/7] Mix format --- lib/mix/tasks/gradient.ex | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/mix/tasks/gradient.ex b/lib/mix/tasks/gradient.ex index 084ea825..93ae63c5 100644 --- a/lib/mix/tasks/gradient.ex +++ b/lib/mix/tasks/gradient.ex @@ -126,7 +126,11 @@ defmodule Mix.Tasks.Gradient 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 + filename = + if String.starts_with?(filename, cwd), + do: String.slice(filename, cwd_range), + else: filename + IO.puts(filename) end end) @@ -374,7 +378,8 @@ defmodule Mix.Tasks.Gradient do |> 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? = + if should_include?, do: has_magic_comment?, else: not has_magic_comment? has_magic_comment? and not is_dep end) From 84d50d8a9d666c989c3a3d518af1cea51d39c502 Mon Sep 17 00:00:00 2001 From: Jan Bergeson Date: Thu, 10 Nov 2022 13:02:32 -0700 Subject: [PATCH 6/7] Fix tests for CI --- test/mix/tasks/gradient_test.exs | 93 +++++++++++++++++--------------- 1 file changed, 50 insertions(+), 43 deletions(-) diff --git a/test/mix/tasks/gradient_test.exs b/test/mix/tasks/gradient_test.exs index c35bc22d..08032bf9 100644 --- a/test/mix/tasks/gradient_test.exs +++ b/test/mix/tasks/gradient_test.exs @@ -417,32 +417,39 @@ defmodule Mix.Tasks.GradientTest do |> Map.get(:apps_files) end + # Strip off "_build/dev/" or "_build/test/" from the beginning of a path + defp strip_beam_path("_build/dev/" <> path), do: path + defp strip_beam_path("_build/test/" <> path), do: path + defp strip_beam_path(path), do: path + @tag umbrella: %{no_config: true}, app_a: %{no_config: true}, app_b: %{no_config: true} test "defaults to enabled when no gradient config in any mix.exs files" do - assert %{ - "app_a" => [ - "_build/dev/lib/app_a/ebin/Elixir.AppA.beam", - "_build/dev/lib/app_a/ebin/Elixir.AppAHelper.beam" - ], - "app_b" => [ - "_build/dev/lib/app_b/ebin/Elixir.AppB.beam", - "_build/dev/lib/app_b/ebin/Elixir.AppBHelper.beam" - ] - } == run_task_and_return_files() + assert %{"app_a" => app_a_files, "app_b" => app_b_files} = run_task_and_return_files() + + assert [ + "lib/app_a/ebin/Elixir.AppA.beam", + "lib/app_a/ebin/Elixir.AppAHelper.beam" + ] == Enum.map(app_a_files, &strip_beam_path/1) + + assert [ + "lib/app_b/ebin/Elixir.AppB.beam", + "lib/app_b/ebin/Elixir.AppBHelper.beam" + ] == Enum.map(app_b_files, &strip_beam_path/1) end @tag umbrella: %{enabled: true}, app_a: %{no_config: true}, app_b: %{no_config: true} test "when gradient is enabled for umbrella, checks all files" do - assert %{ - "app_a" => [ - "_build/dev/lib/app_a/ebin/Elixir.AppA.beam", - "_build/dev/lib/app_a/ebin/Elixir.AppAHelper.beam" - ], - "app_b" => [ - "_build/dev/lib/app_b/ebin/Elixir.AppB.beam", - "_build/dev/lib/app_b/ebin/Elixir.AppBHelper.beam" - ] - } == run_task_and_return_files() + assert %{"app_a" => app_a_files, "app_b" => app_b_files} = run_task_and_return_files() + + assert [ + "lib/app_a/ebin/Elixir.AppA.beam", + "lib/app_a/ebin/Elixir.AppAHelper.beam" + ] == Enum.map(app_a_files, &strip_beam_path/1) + + assert [ + "lib/app_b/ebin/Elixir.AppB.beam", + "lib/app_b/ebin/Elixir.AppBHelper.beam" + ] == Enum.map(app_b_files, &strip_beam_path/1) end @tag umbrella: %{enabled: false}, app_a: %{no_config: true}, app_b: %{no_config: true} @@ -452,42 +459,42 @@ defmodule Mix.Tasks.GradientTest do @tag umbrella: %{enabled: false}, app_a: %{enabled: true} test "gradient can be enabled for a subapp even if disabled for umbrella" do - assert %{ - "app_a" => [ - "_build/dev/lib/app_a/ebin/Elixir.AppA.beam", - "_build/dev/lib/app_a/ebin/Elixir.AppAHelper.beam" - ] - } == run_task_and_return_files() + assert %{"app_a" => app_a_files} = run_task_and_return_files() + + assert [ + "lib/app_a/ebin/Elixir.AppA.beam", + "lib/app_a/ebin/Elixir.AppAHelper.beam" + ] == Enum.map(app_a_files, &strip_beam_path/1) end @tag umbrella: %{enabled: true}, app_a: %{enabled: false} test "gradient can be enabled for the umbrella and disabled for a subapp" do - assert %{ - "app_b" => [ - "_build/dev/lib/app_b/ebin/Elixir.AppB.beam", - "_build/dev/lib/app_b/ebin/Elixir.AppBHelper.beam" - ] - } == run_task_and_return_files() + assert %{"app_b" => app_b_files} = run_task_and_return_files() + + assert [ + "lib/app_b/ebin/Elixir.AppB.beam", + "lib/app_b/ebin/Elixir.AppBHelper.beam" + ] == Enum.map(app_b_files, &strip_beam_path/1) end @tag umbrella: %{enabled: true, overrides: true}, edit_files: %{"app_a/lib/app_a_helper.ex" => false} test "individual files can be disabled" do - assert %{ - "app_a" => [ - "_build/dev/lib/app_a/ebin/Elixir.AppA.beam" - ], - "app_b" => [ - "_build/dev/lib/app_b/ebin/Elixir.AppB.beam", - "_build/dev/lib/app_b/ebin/Elixir.AppBHelper.beam" - ] - } == run_task_and_return_files() + assert %{"app_a" => app_a_files, "app_b" => app_b_files} = run_task_and_return_files() + + assert ["lib/app_a/ebin/Elixir.AppA.beam"] == Enum.map(app_a_files, &strip_beam_path/1) + + assert [ + "lib/app_b/ebin/Elixir.AppB.beam", + "lib/app_b/ebin/Elixir.AppBHelper.beam" + ] == Enum.map(app_b_files, &strip_beam_path/1) end @tag umbrella: %{enabled: false, overrides: true}, edit_files: %{"app_a/lib/app_a.ex" => true} test "individual files can be enabled" do - assert %{"app_a" => ["_build/dev/lib/app_a/ebin/Elixir.AppA.beam"]} == - run_task_and_return_files() + assert %{"app_a" => app_a_files} = run_task_and_return_files() + + assert ["lib/app_a/ebin/Elixir.AppA.beam"] == Enum.map(app_a_files, &strip_beam_path/1) end end From ac4c260eb4e82f0b1251b2877668e5613a9dcd5e Mon Sep 17 00:00:00 2001 From: Jan Bergeson Date: Thu, 10 Nov 2022 13:30:55 -0700 Subject: [PATCH 7/7] Improve error message in ElixirFileUtils.load_tokens --- lib/gradient/elixir_file_utils.ex | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/gradient/elixir_file_utils.ex b/lib/gradient/elixir_file_utils.ex index 3e329b9c..a4736b15 100644 --- a/lib/gradient/elixir_file_utils.ex +++ b/lib/gradient/elixir_file_utils.ex @@ -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), - code <- File.read!(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