Skip to content

Commit 06f3d83

Browse files
committed
Add elixir checker module
1 parent 6f11ee3 commit 06f3d83

File tree

2 files changed

+90
-1
lines changed

2 files changed

+90
-1
lines changed

lib/gradient.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ defmodule Gradient do
1010
alias Gradient.ElixirFileUtils
1111
alias Gradient.ElixirFmt
1212
alias Gradient.AstSpecifier
13+
alias Gradient.ElixirChecker
1314

1415
require Logger
1516

@@ -25,7 +26,7 @@ defmodule Gradient do
2526
|> put_code_path(opts)
2627
|> AstSpecifier.specify()
2728

28-
case :gradualizer.type_check_forms(forms, opts) do
29+
case ElixirChecker.check(forms, opts) ++ :gradualizer.type_check_forms(forms, opts) do
2930
[] ->
3031
:ok
3132

lib/gradient/elixir_checker.ex

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
defmodule Gradient.ElixirChecker do
2+
@moduledoc ~s"""
3+
Provide checks specific to Elixir that complement type checking delivered by Gradient.
4+
5+
Options:
6+
- {`ex_check`, boolean()}: whether to use checks specific only to Elixir.
7+
"""
8+
9+
@spec check([:erl_parse.abstract_form()], keyword()) :: [{:file.filename(), any()}]
10+
def check(forms, opts) do
11+
if Keyword.get(opts, :ex_check, true) do
12+
check_spec(forms)
13+
else
14+
[]
15+
end
16+
end
17+
18+
@doc ~s"""
19+
Check if all specs are exactly before the function that they specify
20+
and if there is only one spec per function clause.
21+
22+
Correct specs location:
23+
```
24+
@spec convert(integer()) :: float()
25+
def convert(int) when is_integer(int), do: int / 1
26+
@spec convert(atom()) :: binary()
27+
def convert(atom) when is_atom(atom), do: to_string(atom)
28+
```
29+
30+
Incorrect specs location:
31+
- More than one spec above function clause.
32+
```
33+
@spec convert(integer()) :: float()
34+
@spec convert(atom()) :: binary()
35+
def convert(int) when is_integer(int), do: int / 1
36+
37+
def convert(atom) when is_atom(atom), do: to_string(atom)
38+
```
39+
40+
- Spec name doesn't match the function name.
41+
```
42+
@spec last_two(atom()) :: atom()
43+
def last_three(:ok) do
44+
:ok
45+
end
46+
```
47+
"""
48+
@spec check_spec([:erl_parse.abstract_form()]) :: [{:file.filename(), any()}]
49+
def check_spec([{:attribute, _, :file, {file, _}} | forms]) do
50+
forms
51+
|> Stream.filter(&is_fun_or_spec?/1)
52+
|> Stream.map(&simplify_form/1)
53+
|> Stream.concat()
54+
|> Stream.filter(&has_line/1)
55+
|> Enum.sort(&(elem(&1, 2) < elem(&2, 2)))
56+
|> Enum.reduce({nil, []}, fn
57+
{:fun, fna, _} = fun, {{:spec, {n, a} = sna, anno}, errors} when fna != sna ->
58+
# Spec name doesn't match the function name
59+
{fun, [{:spec_error, :wrong_spec_name, anno, n, a} | errors]}
60+
61+
{:spec, {n, a}, anno} = s1, {{:spec, _, _}, errors} ->
62+
# Only one spec per function clause is allowed
63+
{s1, [{:spec_error, :spec_after_spec, anno, n, a} | errors]}
64+
65+
x, {_, errors} ->
66+
{x, errors}
67+
end)
68+
|> elem(1)
69+
|> Enum.map(&{file, &1})
70+
end
71+
72+
# Filter out __info__ generated function
73+
def has_line(form), do: :erl_anno.line(elem(form, 2)) > 1
74+
75+
def is_fun_or_spec?({:attribute, _, :spec, _}), do: true
76+
def is_fun_or_spec?({:function, _, _, _, _}), do: true
77+
def is_fun_or_spec?(_), do: false
78+
79+
@spec simplify_form(:erl_parse.abstract_form()) ::
80+
Enumerable.t({:spec | :fun, {atom(), integer()}, :erl_anno.anno()})
81+
def simplify_form({:attribute, _, :spec, {{name, arity}, types}}) do
82+
Stream.map(types, &{:spec, {name, arity}, elem(&1, 1)})
83+
end
84+
85+
def simplify_form({:function, _, name, arity, clauses}) do
86+
Stream.map(clauses, &{:fun, {name, arity}, elem(&1, 1)})
87+
end
88+
end

0 commit comments

Comments
 (0)