Skip to content

Commit d5dd767

Browse files
authored
Merge pull request #102 from esl/do-not-check-macro-injected-code
Do not check macro injected code
2 parents 7bd3123 + 163834b commit d5dd767

File tree

7 files changed

+115
-24
lines changed

7 files changed

+115
-24
lines changed

.tool-versions

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
elixir 1.12.3
2-
erlang 24.3.4
1+
elixir 1.13.4-otp-24
2+
erlang 24.1

lib/gradient.ex

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ defmodule Gradient do
2525
no_specify: boolean()
2626
]
2727

28+
@type env() :: %{tokens_present: boolean(), macro_lines: [integer()]}
29+
2830
@doc """
2931
Type-checks file in `path` with provided `opts`, and prints the result.
3032
"""
@@ -37,16 +39,22 @@ defmodule Gradient do
3739
{:ok, first_ast} <- get_first_forms(asts),
3840
{:elixir, _} <- wrap_language_name(first_ast) do
3941
asts
40-
|> Enum.map(fn module_forms ->
41-
single_module_forms = maybe_specify_forms(module_forms, opts)
42+
|> Enum.map(fn ast ->
43+
ast =
44+
ast
45+
|> put_source_path(opts)
46+
|> maybe_specify_forms(opts)
47+
48+
tokens = maybe_use_tokens(ast, opts)
49+
opts = [{:env, build_env(tokens)} | opts]
4250

43-
case maybe_gradient_check(single_module_forms, opts) ++
44-
maybe_gradualizer_check(single_module_forms, opts) do
51+
case maybe_gradient_check(ast, opts) ++
52+
maybe_gradualizer_check(ast, opts) do
4553
[] ->
4654
:ok
4755

4856
errors ->
49-
opts = Keyword.put(opts, :forms, single_module_forms)
57+
opts = Keyword.put(opts, :forms, ast)
5058
ElixirFmt.print_errors(errors, opts)
5159

5260
{:error, errors}
@@ -74,6 +82,18 @@ defmodule Gradient do
7482
end
7583
end
7684

85+
def build_env(tokens) do
86+
%{tokens_present: tokens != [], macro_lines: Gradient.Tokens.find_macro_lines(tokens)}
87+
end
88+
89+
defp maybe_use_tokens(forms, opts) do
90+
unless opts[:no_tokens] do
91+
Gradient.ElixirFileUtils.load_tokens(forms)
92+
else
93+
[]
94+
end
95+
end
96+
7797
defp maybe_gradualizer_check(forms, opts) do
7898
opts = Keyword.put(opts, :return_errors, true)
7999

lib/gradient/elixir_checker.ex

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ defmodule Gradient.ElixirChecker do
66
- {`ex_check`, boolean()}: whether to use checks specific only to Elixir.
77
"""
88

9+
@type env() :: Gradient.env()
10+
@type opts :: [env: env(), ex_check: boolean()]
911
@type simplified_form :: {:spec | :fun, {atom(), integer()}, :erl_anno.anno()}
1012

11-
@spec check([:erl_parse.abstract_form()], keyword()) :: [{:file.filename(), any()}]
13+
@spec check([:erl_parse.abstract_form()], opts()) :: [{:file.filename(), any()}]
1214
def check(forms, opts) do
1315
if Keyword.get(opts, :ex_check, true) do
14-
check_spec(forms)
16+
check_spec(forms, opts[:env])
1517
else
1618
[]
1719
end
@@ -48,13 +50,16 @@ defmodule Gradient.ElixirChecker do
4850
end
4951
```
5052
"""
51-
@spec check_spec([:erl_parse.abstract_form()]) :: [{:file.filename(), any()}]
52-
def check_spec([{:attribute, _, :file, {file, _}} | forms]) do
53+
@spec check_spec([:erl_parse.abstract_form()], map()) :: [{:file.filename(), any()}]
54+
def check_spec([{:attribute, _, :file, {file, _}} | forms], env) do
55+
%{tokens_present: tokens_present, macro_lines: macro_lines} = env
56+
5357
forms
54-
|> Stream.filter(&is_fun_or_spec?/1)
58+
|> Stream.filter(&is_fun_or_spec?(&1, macro_lines))
5559
|> Stream.map(&simplify_form/1)
5660
|> Stream.concat()
5761
|> Stream.filter(&is_not_generated?/1)
62+
|> remove_injected_forms(not tokens_present)
5863
|> Enum.sort(&(elem(&1, 2) < elem(&2, 2)))
5964
|> Enum.reduce({nil, []}, fn
6065
{:fun, {n, :def}, _}, {{:spec, {sn, _}, _}, _} = acc when n == sn ->
@@ -83,9 +88,10 @@ defmodule Gradient.ElixirChecker do
8388
not (String.starts_with?(name_str, "__") and String.ends_with?(name_str, "__"))
8489
end
8590

86-
def is_fun_or_spec?({:attribute, _, :spec, _}), do: true
87-
def is_fun_or_spec?({:function, _, _, _, _}), do: true
88-
def is_fun_or_spec?(_), do: false
91+
# The forms injected by `__using__` macro inherit the line from `use` keyword.
92+
def is_fun_or_spec?({:attribute, anno, :spec, _}, ml), do: :erl_anno.line(anno) not in ml
93+
def is_fun_or_spec?({:function, anno, _, _, _}, ml), do: :erl_anno.line(anno) not in ml
94+
def is_fun_or_spec?(_, _), do: false
8995

9096
@doc """
9197
Returns a stream of simplified forms in the format defined by type `simplified_form/1`
@@ -115,4 +121,15 @@ defmodule Gradient.ElixirChecker do
115121
_ -> false
116122
end)
117123
end
124+
125+
# When tokens were not present to detect macro_lines, the forms without unique
126+
# lines can be removed.
127+
def remove_injected_forms(forms, true) do
128+
forms
129+
|> Enum.group_by(fn {_, _, line} -> line end)
130+
|> Enum.filter(fn {_, fs2} -> length(fs2) == 1 end)
131+
|> Enum.flat_map(fn {_, fs2} -> fs2 end)
132+
end
133+
134+
def remove_injected_forms(forms, false), do: forms
118135
end

lib/gradient/tokens.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ defmodule Gradient.Tokens do
6060
end
6161
end
6262

63+
def find_macro_lines([
64+
{:identifier, {l, _, _}, :use},
65+
{:alias, {l, _, _}, _},
66+
{:eol, {l, _, _}} | t
67+
]) do
68+
[l | find_macro_lines(t)]
69+
end
70+
71+
def find_macro_lines([_ | t]) do
72+
find_macro_lines(t)
73+
end
74+
75+
def find_macro_lines([]) do
76+
[]
77+
end
78+
6379
@doc """
6480
Drop tokens to the first tuple occurrence. Returns type of the encountered
6581
list and the following tokens.

lib/mix/tasks/gradient.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ defmodule Mix.Tasks.Gradient do
1212
* `--no-gradualizer-check` - do not perform the Gradualizer checks
1313
* `--no-specify` - do not specify missing lines in AST what can
1414
result in less precise error messages
15-
* `--source-path` - provide a path to the .ex file containing code for analyzed .beam
15+
* `--source-path` - provide a path to the .ex file containing code for analyzed .beam
16+
* `--no-tokens` - do not use tokens to increase the precision of typechecking
1617
1718
* `--no-deps` - do not import dependencies to the Gradualizer
1819
* `--stop_on_first_error` - stop type checking at the first error
@@ -44,6 +45,7 @@ defmodule Mix.Tasks.Gradient do
4445
no_specify: :boolean,
4546
# checker options
4647
source_path: :string,
48+
no_tokens: :boolean,
4749
no_deps: :boolean,
4850
stop_on_first_error: :boolean,
4951
infer: :boolean,

test/examples/spec_in_macro.ex

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
defmodule NewMod do
2+
defmacro __using__(_) do
3+
quote do
4+
@spec new(attrs :: map()) :: atom()
5+
def new(_attrs), do: :ok
6+
7+
@spec a(attrs :: map()) :: atom()
8+
def a(_attrs), do: :ok
9+
10+
@spec b(attrs :: map()) :: atom()
11+
def b(_attrs), do: :ok
12+
end
13+
end
14+
end
15+
16+
defmodule SpecInMacro do
17+
use NewMod
18+
19+
@spec c(attrs :: map()) :: atom()
20+
def c(_attrs), do: :ok
21+
end

test/gradient/elixir_checker_test.exs

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,26 @@ defmodule Gradient.ElixirCheckerTest do
99
test "checker options" do
1010
ast = load("Elixir.SpecWrongName.beam")
1111

12-
assert [] = ElixirChecker.check(ast, ex_check: false)
13-
assert [] != ElixirChecker.check(ast, ex_check: true)
12+
assert [] = ElixirChecker.check(ast, env([], ex_check: false))
13+
assert [] != ElixirChecker.check(ast, env([], ex_check: true))
1414
end
1515

1616
test "all specs are correct" do
1717
ast = load("Elixir.CorrectSpec.beam")
1818

19-
assert [] = ElixirChecker.check(ast, ex_check: true)
19+
assert [] = ElixirChecker.check(ast, env())
2020
end
2121

2222
test "specs over default args are correct" do
2323
ast = load("Elixir.SpecDefaultArgs.beam")
2424

25-
assert [] = ElixirChecker.check(ast, ex_check: true)
25+
assert [] = ElixirChecker.check(ast, env())
2626
end
2727

2828
test "spec arity doesn't match the function arity" do
2929
ast = load("Elixir.SpecWrongArgsArity.beam")
3030

31-
assert [{_, {:spec_error, :wrong_spec_name, 2, :foo, 3}}] =
32-
ElixirChecker.check(ast, ex_check: true)
31+
assert [{_, {:spec_error, :wrong_spec_name, 2, :foo, 3}}] = ElixirChecker.check(ast, env())
3332
end
3433

3534
test "spec name doesn't match the function name" do
@@ -38,7 +37,7 @@ defmodule Gradient.ElixirCheckerTest do
3837
assert [
3938
{_, {:spec_error, :wrong_spec_name, 5, :convert, 1}},
4039
{_, {:spec_error, :wrong_spec_name, 11, :last_two, 1}}
41-
] = ElixirChecker.check(ast, [])
40+
] = ElixirChecker.check(ast, env())
4241
end
4342

4443
test "mixing specs names is not allowed" do
@@ -47,6 +46,22 @@ defmodule Gradient.ElixirCheckerTest do
4746
assert [
4847
{_, {:spec_error, :mixed_specs, 3, :encode, 1}},
4948
{_, {:spec_error, :wrong_spec_name, 3, :encode, 1}}
50-
] = ElixirChecker.check(ast, [])
49+
] = ElixirChecker.check(ast, env())
50+
end
51+
52+
test "spec defined in a __using__ macro with tokens" do
53+
{tokens, ast} = load("Elixir.SpecInMacro.beam", "spec_in_macro.ex")
54+
55+
assert [] = ElixirChecker.check(ast, env(tokens))
56+
end
57+
58+
test "spec defined in a __using__ macro without tokens" do
59+
ast = load("Elixir.SpecInMacro.beam")
60+
61+
assert [] = ElixirChecker.check(ast, env())
62+
end
63+
64+
defp env(tokens \\ [], opts \\ []) do
65+
[{:env, Gradient.build_env(tokens)} | opts]
5166
end
5267
end

0 commit comments

Comments
 (0)