Skip to content

Commit 60d7d3f

Browse files
authored
Merge pull request #55 from esl/spec-attr-check
Introduce a module for checks specific to Elixir
2 parents 080a6bd + 9c5f3aa commit 60d7d3f

14 files changed

+217
-3
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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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 spec locations:
23+
```
24+
@spec convert(integer()) :: float()
25+
def convert(int) when is_integer(int), do: int / 1
26+
27+
@spec convert(atom()) :: binary()
28+
def convert(atom) when is_atom(atom), do: to_string(atom)
29+
```
30+
31+
Incorrect spec locations:
32+
- More than one spec above function clause.
33+
```
34+
@spec convert(integer()) :: float()
35+
@spec convert(atom()) :: binary()
36+
def convert(int) when is_integer(int), do: int / 1
37+
38+
def convert(atom) when is_atom(atom), do: to_string(atom)
39+
```
40+
41+
- Spec name doesn't match the function name.
42+
```
43+
@spec last_two(atom()) :: atom()
44+
def last_three(:ok) do
45+
:ok
46+
end
47+
```
48+
"""
49+
@spec check_spec([:erl_parse.abstract_form()]) :: [{:file.filename(), any()}]
50+
def check_spec([{:attribute, _, :file, {file, _}} | forms]) do
51+
forms
52+
|> Stream.filter(&is_fun_or_spec?/1)
53+
|> Stream.map(&simplify_form/1)
54+
|> Stream.concat()
55+
|> Stream.filter(&has_line/1)
56+
|> Enum.sort(&(elem(&1, 2) < elem(&2, 2)))
57+
|> Enum.reduce({nil, []}, fn
58+
{:fun, fna, _} = fun, {{:spec, {n, a} = sna, anno}, errors} when fna != sna ->
59+
# Spec name doesn't match the function name
60+
{fun, [{:spec_error, :wrong_spec_name, anno, n, a} | errors]}
61+
62+
{:spec, {n, a}, anno} = s1, {{:spec, _, _}, errors} ->
63+
# Only one spec per function clause is allowed
64+
{s1, [{:spec_error, :spec_after_spec, anno, n, a} | errors]}
65+
66+
x, {_, errors} ->
67+
{x, errors}
68+
end)
69+
|> elem(1)
70+
|> Enum.map(&{file, &1})
71+
end
72+
73+
# Filter out __info__ generated function
74+
def has_line(form), do: :erl_anno.line(elem(form, 2)) > 1
75+
76+
def is_fun_or_spec?({:attribute, _, :spec, _}), do: true
77+
def is_fun_or_spec?({:function, _, _, _, _}), do: true
78+
def is_fun_or_spec?(_), do: false
79+
80+
@spec simplify_form(:erl_parse.abstract_form()) ::
81+
Enumerable.t({:spec | :fun, {atom(), integer()}, :erl_anno.anno()})
82+
def simplify_form({:attribute, _, :spec, {{name, arity}, types}}) do
83+
Stream.map(types, &{:spec, {name, arity}, elem(&1, 1)})
84+
end
85+
86+
def simplify_form({:function, _, name, arity, clauses}) do
87+
Stream.map(clauses, &{:fun, {name, arity}, elem(&1, 1)})
88+
end
89+
end

lib/gradient/elixir_fmt.ex

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,33 @@ defmodule Gradient.ElixirFmt do
6969
format_expr_type_error(expression, actual_type, expected_type, opts)
7070
end
7171

72+
def format_type_error(
73+
{:spec_error, :wrong_spec_name, anno, name, arity},
74+
opts
75+
) do
76+
:io_lib.format(
77+
"~sThe spec ~p/~p~s doesn't match the function name/arity~n",
78+
[
79+
format_location(anno, :brief, opts),
80+
name,
81+
arity,
82+
format_location(anno, :verbose, opts)
83+
]
84+
)
85+
end
86+
87+
def format_type_error({:spec_error, :spec_after_spec, anno, name, arity}, opts) do
88+
:io_lib.format(
89+
"~sThe spec ~p/~p~s follows another spec, but only one spec per function clause is allowed~n",
90+
[
91+
format_location(anno, :brief, opts),
92+
name,
93+
arity,
94+
format_location(anno, :verbose, opts)
95+
]
96+
)
97+
end
98+
7299
def format_type_error({:call_undef, anno, module, func, arity}, opts) do
73100
:io_lib.format(
74101
"~sCall to undefined function ~s~p/~p~s~n",
1.56 KB
Binary file not shown.
1.58 KB
Binary file not shown.
1.98 KB
Binary file not shown.

test/examples/spec_after_spec.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
defmodule SpecAfterSpec do
2+
@spec convert(integer()) :: float()
3+
@spec convert(atom()) :: binary()
4+
def convert(int) when is_integer(int), do: int / 1
5+
def convert(atom) when is_atom(atom), do: to_string(atom)
6+
end

test/examples/spec_correct.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
defmodule CorrectSpec do
2+
@spec convert(integer()) :: float()
3+
def convert(int) when is_integer(int), do: int / 1
4+
@spec convert(atom()) :: binary()
5+
def convert(atom) when is_atom(atom), do: to_string(atom)
6+
end

test/examples/spec_wrong_name.ex

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
defmodule SpecWrongName do
2+
@spec convert(integer()) :: float()
3+
def convert(int) when is_integer(int), do: int / 1
4+
5+
@spec convert(atom()) :: binary()
6+
def last_two(list) do
7+
[last, penultimate | _tail] = Enum.reverse(list)
8+
[penultimate, last]
9+
end
10+
11+
@spec last_two(atom()) :: atom()
12+
def last_three(:ok) do
13+
:ok
14+
end
15+
end

test/gradient/ast_specifier_test.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1585,8 +1585,10 @@ defmodule Gradient.AstSpecifierTest do
15851585

15861586
assert {:function, 3, :name, 0, [{:clause, 3, [], [], [{:atom, 4, :module_a}]}]} =
15871587
List.last(AstSpecifier.run_mappers(astA, tokensA))
1588+
15881589
assert {:function, 9, :name, 0, [{:clause, 9, [], [], [{:atom, 10, :module_b}]}]} =
15891590
List.last(AstSpecifier.run_mappers(astB, tokensB))
1591+
15901592
assert {:function, 14, :name, 0, [{:clause, 14, [], [], [{:atom, 15, :module}]}]} =
15911593
List.last(AstSpecifier.run_mappers(ast, tokens))
15921594
end

0 commit comments

Comments
 (0)