Fixed parse error reporting.
[erlang-estap.git] / src / estap_file.erl
1 %%%---------------------------------------------------------------------------
2 %%% @doc
3 %%%   Functions to work with files.
4 %%% @end
5 %%%---------------------------------------------------------------------------
6
7 -module(estap_file).
8
9 %% public interface
10 -export([tempdir/0, tempdir/1]).
11 -export([read_file/2, read_file/3]).
12
13 -export([load_code/1]).
14
15 %%%---------------------------------------------------------------------------
16 %%% types and definitions {{{
17
18 -define(STEM_PREFIX, "estap").
19 -define(STEM_LEN, 8).
20 -define(DEFAULT_TMP, "/tmp").
21
22 -record(test, {
23   %% TODO: `-prep(fun/0)', `-cleanup(fun/1)'
24   name :: atom(),
25   desc :: string(),
26   todo = false :: {true, Reason :: string()} | false,
27   skip = false :: {true, Reason :: string()} | false
28 }).
29
30 %%% }}}
31 %%%---------------------------------------------------------------------------
32 %%% public interface
33 %%%---------------------------------------------------------------------------
34
35 %%----------------------------------------------------------
36 %% temporary directories {{{
37
38 %% @doc Create a temporary directory of unique name.
39 %%   The directory is created under `$TMP', or `/tmp' if the variable is not
40 %%   set.
41
42 -spec tempdir() ->
43   {ok, file:filename()} | {error, term()}.
44
45 tempdir() ->
46   case os:getenv("TMP") of
47     Dir when is_list(Dir) ->
48       tempdir(Dir);
49     false ->
50       tempdir(?DEFAULT_TMP)
51   end.
52
53 %% @doc Create a temporary directory of unique name under specified directory.
54
55 -spec tempdir(file:name()) ->
56   {ok, file:filename()} | {error, term()}.
57
58 tempdir(TempDir) ->
59   StemSeed = crypto:sha(term_to_binary(make_ref())),
60   <<Stem:?STEM_LEN/binary, _/binary>> = base64:encode(StemSeed),
61   DirName = filename:join(TempDir, ?STEM_PREFIX ++ "." ++ binary_to_list(Stem)),
62   case file:make_dir(DirName) of
63     ok ->
64       {ok, DirName};
65     {error, eexist} ->
66       % try again, maybe next one will succeed
67       tempdir(TempDir);
68     {error, Reason} ->
69       {error, Reason}
70   end.
71
72 %% }}}
73 %%----------------------------------------------------------
74 %% parsing test files {{{
75
76 %% @doc Load estap file as ABF forms.
77
78 -spec read_file(file:name(), [file:name()]) ->
79   {ok, {module(), [erl_parse:abstract_form()]}} | {error, term()}.
80
81 read_file(File, IncludePath) ->
82   read_file(File, IncludePath, ?DEFAULT_TMP).
83
84 %% @doc Load estap file as ABF forms.
85
86 -spec read_file(file:name(), [file:name()], file:name()) ->
87   {ok, {module(), [erl_parse:abstract_form()]}} | {error, term()}.
88
89 read_file(File, IncludePath, TempDir) ->
90   case tempdir(TempDir) of
91     {ok, DirName} ->
92       case copy_source(File, DirName) of
93         {ok, ModuleFile} ->
94           Result = parse_file(ModuleFile, File, IncludePath),
95           ok = file:delete(ModuleFile),
96           ok = file:del_dir(DirName),
97           Result;
98         {error, Reason} ->
99           ok = file:del_dir(TempDir),
100           {error, Reason}
101       end;
102     {error, Reason} ->
103       {error, Reason}
104   end.
105
106 %% @doc Copy estap test source to target directory, stripping shee-bang lines.
107
108 -spec copy_source(file:name(), file:name()) ->
109   {ok, file:name()} | {error, term()}.
110
111 copy_source(Source, TargetDir) ->
112   Target = filename:join(TargetDir, filename:basename(Source)),
113   case file:read_file(Source) of
114     {ok, Content} ->
115       % adding a comment just before "#!" preserves line numbering, so stack
116       % traces when test case dies are accurate
117       SourceCode = case Content of
118         <<"#!", _/binary>> -> <<"%%% ", Content/binary>>;
119         _ -> Content
120       end,
121       case file:write_file(Target, SourceCode) of
122         ok -> {ok, Target};
123         {error, Reason} -> {error, Reason}
124       end;
125     {error, Reason} ->
126       {error, Reason}
127   end.
128
129 %% @doc Parse specified file to ABF forms.
130
131 -spec parse_file(file:name(), file:name(), [file:name()]) ->
132   {ok, {module(), [erl_parse:abstract_form()]}} | {error, term()}.
133
134 parse_file(File, Source, IncludePath) ->
135   Macros = [],
136   case epp:parse_file(File, IncludePath, Macros) of
137     {ok, Forms} ->
138       % replace name of the file `epp:parse_file()' actually read with the
139       % name of source file, so any possible stack traces mention this source,
140       % not a temporary file
141       NewForms = lists:map(
142         fun
143           ({attribute,N1,file,{_File,N2}}) -> {attribute,N1,file,{Source,N2}};
144           (Form) -> Form
145         end,
146         Forms
147       ),
148       case [M || {attribute, _, module, M} <- Forms] of
149         % in case of two `-module()' entries let the `compile:forms()' raise
150         % an error
151         [ModuleName | _] ->
152           {ok, {ModuleName, NewForms}};
153         [] ->
154           ModuleName = list_to_atom(filename:rootname(filename:basename(File))),
155           {ok, {ModuleName, [{attribute, 0, module, ModuleName} | NewForms]}}
156       end;
157     {error, Reason} ->
158       {error, Reason}
159   end.
160
161 %% }}}
162 %%----------------------------------------------------------
163 %% ABF handling functions {{{
164
165 %% @doc Load ABFs as a callable module.
166 %%   Function returns list of tests to run, in order of their appearance.
167
168 -spec load_code([erl_parse:abstract_form()]) ->
169     {ok, {estap_test:test_plan(), [estap_test:test()]}}
170   | {error, sticky_directory | not_purged}.
171
172 load_code(Forms) ->
173   Exports = sets:from_list(exports(Forms)),
174   Tests = tests(Forms),
175   MissingTestExports = [
176     {Fun, 0} ||
177     #test{name = Fun} <- Tests,
178     not sets:is_element({Fun, 0}, Exports)
179   ],
180   % drop all occurrences of `-test()', `-todo()', and `-skip()'
181   ToCompile = lists:filter(
182     fun
183       ({attribute, _, A, _}) when A == test; A == todo; A == skip -> false;
184       (_) -> true
185     end,
186     insert_exports(MissingTestExports, Forms)
187   ),
188   case compile:forms(ToCompile, [return_errors]) of
189     {ok, Module, Binary} ->
190       case code:load_binary(Module, "", Binary) of
191         {module, Module} ->
192           % TODO: indicate whether anything uses the old code
193           code:soft_purge(Module),
194           TestsToReturn = lists:map(
195             fun
196               (#test{name = Name, desc = Desc, todo = {true, Why}}) ->
197                 {{Module, Name}, Desc, {todo, Why}};
198               (#test{name = Name, desc = Desc, skip = {true, Why}}) ->
199                 {{Module, Name}, Desc, {skip, Why}};
200               (#test{name = Name, desc = Desc, todo = false, skip = false}) ->
201                 {{Module, Name}, Desc, run}
202             end,
203             Tests
204           ),
205           case proplists:get_value(plan, Module:module_info(attributes)) of
206             [TestCount] when is_integer(TestCount), TestCount > 0 ->
207               Plan = {plan, TestCount};
208             _ ->
209               Plan = no_plan
210           end,
211           {ok, {Plan, TestsToReturn}};
212         {error, Reason} ->
213           {error, Reason}
214       end;
215     {error, Errors, _Warnings} ->
216       {error, {parse_errors, Errors}}
217   end.
218
219 %% @doc Insert specified exports in list of ABFs for the module.
220
221 insert_exports(Exports, [{attribute,_,module,_} = Attr | Rest] = _Forms) ->
222   [Attr, {attribute, 0, export, Exports} | Rest];
223 insert_exports(Exports, [Attr | Rest] = _Forms) ->
224   [Attr | insert_exports(Exports, Rest)].
225
226 %% @doc Extract from list of ABFs functions that are tests to be run.
227
228 -spec tests([erl_parse:abstract_form()]) ->
229   [#test{}].
230
231 tests(Forms) ->
232   tests(Forms, #test{}).
233
234 %% @doc Extract from list of ABFs functions that are tests to be run.
235 %%   Worker function for {@link tests/1}.
236
237 -spec tests([erl_parse:abstract_form()], #test{}) ->
238   [#test{}].
239
240 tests([] = _Forms, _Test) ->
241   [];
242
243 tests([{attribute, _Line, test, Desc} | Rest] = _Forms, Test) ->
244   tests(Rest, Test#test{desc = Desc});
245
246 tests([{attribute, _Line, todo, Reason} | Rest] = _Forms, Test) ->
247   tests(Rest, Test#test{todo = {true, Reason}});
248
249 tests([{attribute, _Line, skip, Reason} | Rest] = _Forms, Test) ->
250   tests(Rest, Test#test{skip = {true, Reason}});
251
252 tests([{function, _Line, FName, 0, _Body} | Rest] = _Forms, Test) ->
253   case Test of
254     #test{desc = undefined} ->
255       % TODO: check if `FName' ends with `"_test"'
256       tests(Rest, Test);
257     #test{desc = Desc} when is_list(Desc) ->
258       [Test#test{name = FName} | tests(Rest, #test{})]
259   end;
260
261 tests([{function, _Line, _FName, _Arity, _Body} | Rest] = _Forms, _Test) ->
262   % reset attributes
263   tests(Rest, #test{});
264
265 tests([_Any | Rest] = _Forms, Test) ->
266   tests(Rest, Test).
267
268 %% @doc Extract exports from the module.
269
270 -spec exports([erl_parse:abstract_form()]) ->
271   [{atom(), byte()}].
272
273 exports(Forms) ->
274   Exports = [Fs || {attribute, _Line, export, Fs} <- Forms],
275   lists:flatten(Exports).
276
277 %% }}}
278 %%----------------------------------------------------------
279
280 %%%---------------------------------------------------------------------------
281 %%% vim:ft=erlang:foldmethod=marker