Current section

Files

Jump to
erlfmt src erlfmt_cli.erl
Raw

src/erlfmt_cli.erl

%% Copyright (c) Meta Platforms, Inc. and its affiliates.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
-module(erlfmt_cli).
-export([opts/0, do/2, do/3]).
-type out() :: standard_out | {path, file:name_all()} | replace | check.
-record(config, {
verbose = false :: boolean(),
print_width = undefined :: undefined | pos_integer(),
pragma = ignore :: erlfmt:pragma(),
out = standard_out :: out(),
range = undefined :: undefined | {pos_integer(), pos_integer()}
}).
-type parsed() :: {format, list(), list(), #config{}} | help | version | {error, string()}.
opts() ->
[
{help, $h, "help", undefined, "print this message"},
{version, $v, "version", undefined, "print version"},
{write, $w, "write", undefined, "modify formatted files in place"},
{out, $o, "out", binary, "output directory"},
{verbose, undefined, "verbose", undefined, "include debug output"},
{check, $c, "check", undefined,
"Check if your files are formatted. "
"Get exit code 1, if some files are not formatted. "
"--write is not supported."},
{print_width, undefined, "print-width", integer,
"The line length that formatter would wrap on"},
{require_pragma, undefined, "require-pragma", undefined,
"Require a special comment @format, called a pragma, "
"to be present in the file's first docblock comment in order for erlfmt to format it."},
{insert_pragma, $i, "insert-pragma", undefined,
"Insert a @format pragma to the top of formatted files when pragma is absent. "
"Works well when used in tandem with --require-pragma, "
"but it is not allowed to use require-pragma and insert-pragma at the same time."},
{delete_pragma, undefined, "delete-pragma", undefined,
"Deletes the @format pragma at the top of formatted files. "
"It will also reformat the file, but is only applied to files with a pragma, see --require-pragma."},
% The getopt module doesn't support tuple of integers as a type,
% So we accept a string, and do an ad-hoc parsing manually.
{range, undefined, "range", string,
"Range to be formatted in start-end format (both inclusive). "
"Warning! A bigger range might end up being formatted, as for now the format granularity is top-level form."},
{exclude_files, undefined, "exclude-files", string,
"files not to format. "
"This overrides the files specified to format"},
{files, undefined, undefined, string,
"files to format, - for stdin. "
"If no files are provided and one option of [-w, -o, -c] are provided then "
"{src,include,test}/*.{hrl,erl,app.src} and rebar.config "
"are specified as the default."}
].
-spec do(string(), list()) -> ok.
do(Name, Opts) ->
do(Name, Opts, []).
-spec do(string(), list(), list()) -> ok.
do(Name, PreferOpts, DefaultOpts) ->
PreferParsed = parse_opts(PreferOpts),
DefaultParsed = parse_opts(DefaultOpts),
SpecifiedFiles = specified_files(PreferOpts) ++ specified_files(DefaultOpts),
Parsed =
case {PreferParsed, DefaultParsed, SpecifiedFiles} of
{
{format, _, _, #config{out = standard_out}},
{format, _, _, #config{out = standard_out}},
_
} ->
%% Do not provide default files if we are writing to stdout
resolve_parsed(PreferParsed, DefaultParsed);
{{format, [], _, _}, {format, [], _, _}, _} when SpecifiedFiles =/= [] ->
io:format(standard_error, "no files matching ~p~n", [SpecifiedFiles]),
help;
{_, _, []} ->
%% no files means we should provide default files
DefaultFiles = parse_opts([
{files, ["{src,include,test}/*.{hrl,erl,app.src}", "rebar.config"]}
]),
resolve_parsed(PreferParsed, resolve_parsed(DefaultParsed, DefaultFiles));
_ ->
resolve_parsed(PreferParsed, DefaultParsed)
end,
with_parsed(Name, Parsed).
-spec with_parsed(string(), parsed()) -> ok.
with_parsed(Name, Config) ->
try
unprotected_with_config(Name, Config)
catch
Kind:Reason:Stack ->
io:format(standard_error, "~s Internal Error~n~s:~p~n~p~n", [Name, Kind, Reason, Stack]),
erlang:halt(127)
end.
-spec set_difference([file:name_all()], [file:name_all()]) -> [file:name_all()].
set_difference(Files, Excludes) ->
{ok, Cwd} = file:get_cwd(),
AbsoluteFiles = maps:from_list([{resolve_path(Cwd, F), F} || F <- Files]),
AbsoluteExcludes = [resolve_path(Cwd, E) || E <- Excludes],
maps:values(maps:without(AbsoluteExcludes, AbsoluteFiles)).
resolve_path(Dir, Filename) ->
resolve_path2(filename:absname(Filename, Dir)).
resolve_path2(AbsPath) ->
[Volume | Components] = filename:split(AbsPath),
filename:join(resolve_path2(Components, [Volume])).
resolve_path2([".." | T1], [Volume]) -> resolve_path2(T1, [Volume]);
resolve_path2([<<"..">> | T1], [Volume]) -> resolve_path2(T1, [Volume]);
resolve_path2([".." | T1], [_H2 | T2]) -> resolve_path2(T1, T2);
resolve_path2([<<"..">> | T1], [_H2 | T2]) -> resolve_path2(T1, T2);
resolve_path2([H1 | T1], Components) -> resolve_path2(T1, [H1 | Components]);
resolve_path2([], Components) -> lists:reverse(Components).
%% needed because of getopt
-dialyzer({nowarn_function, [unprotected_with_config/2]}).
-spec unprotected_with_config(string(), parsed()) -> ok.
unprotected_with_config(Name, ParsedConfig) ->
case ParsedConfig of
{format, Files0, Excludes, Config} ->
case set_difference(Files0, Excludes) of
[] ->
io:put_chars(standard_error, ["no files provided to format\n\n"]),
getopt:usage(opts(), Name),
erlang:halt(2);
Files ->
case Config#config.out of
check -> io:format(standard_error, "Checking formatting...~n", []);
_ -> ok
end,
case parallel(fun(File) -> format_file(File, Config) end, Files) of
ok ->
case Config#config.out of
check ->
io:format(
standard_error,
"All matched files use erlfmt code style!~n",
[]
);
_ ->
ok
end;
warn ->
io:format(
standard_error,
"[warn] Code style issues found in the above file(s). Forgot to run erlfmt?~n",
[]
),
erlang:halt(1);
error ->
erlang:halt(4)
end
end;
{error, Message} ->
io:put_chars(standard_error, [Message, "\n\n"]),
getopt:usage(opts(), Name),
erlang:halt(2);
help ->
getopt:usage(opts(), Name),
erlang:halt(0);
version ->
{ok, Vsn} = application:get_key(erlfmt, vsn),
io:format("~s version ~s\n", [Name, Vsn]),
erlang:halt(0)
end.
format_file(FileName, Config) ->
#config{pragma = Pragma, print_width = PrintWidth, verbose = Verbose, out = Out, range = Range} =
Config,
case Verbose of
true -> io:format(standard_error, "Formatting ~s\n", [FileName]);
false -> ok
end,
Options =
[{pragma, Pragma}] ++
[{print_width, PrintWidth} || PrintWidth =/= undefined] ++
[verbose || Verbose] ++
[{range, Range} || Range =/= undefined],
Result =
case {Range, Out, FileName} of
{undefined, check, stdin} ->
check_stdin(Options);
{undefined, check, _} ->
check_file(FileName, Options);
{{_, _}, check, _} ->
{error, "Checking of range not supported."};
{{_, _}, replace, _} ->
{error, "In place formatting of range not supported yet."};
_ ->
erlfmt:format_file(FileName, Options)
end,
case {Verbose, Result} of
{true, {skip, _}} ->
io:format(standard_error, "Skipping ~s because of missing @format pragma\n", [FileName]);
_ ->
ok
end,
case Result of
{ok, FormattedText, Warnings} ->
[print_error_info(Warning) || Warning <- Warnings],
write_formatted(FileName, FormattedText, Out);
{warn, Warnings} ->
[print_error_info(Warning) || Warning <- Warnings],
io:format(standard_error, "[warn] ~s\n", [FileName]),
warn;
{skip, RawString} ->
write_formatted(FileName, RawString, Out);
{error, Error} ->
print_error_info(Error),
error
end.
write_formatted(_FileName, _Formatted, check) ->
ok;
write_formatted(_FileName, Formatted, standard_out) ->
io:put_chars(Formatted);
write_formatted(FileName, Formatted, Out) ->
OutFileName = out_file(FileName, Out),
case filelib:ensure_dir(OutFileName) of
ok ->
ok;
{error, Reason1} ->
print_error_info({OutFileName, 0, file, Reason1}),
error
end,
{ok, OriginalBin} = file:read_file(FileName),
case unicode:characters_to_binary(Formatted) of
OriginalBin -> ok;
FormattedBin -> write_file(OutFileName, FormattedBin)
end.
write_file(OutFileName, FormattedBin) ->
case file:write_file(OutFileName, unicode:characters_to_binary(FormattedBin)) of
ok ->
ok;
{error, Reason2} ->
print_error_info({OutFileName, 0, file, Reason2}),
error
end.
out_file(FileName, replace) ->
FileName;
out_file(FileName, {path, Path}) ->
filename:join(Path, filename:basename(FileName)).
check_file(FileName, Options) ->
case erlfmt:format_file(FileName, Options) of
{ok, Formatted, FormatWarnings} ->
{ok, OriginalBin} = file:read_file(FileName),
FormattedBin = unicode:characters_to_binary(Formatted),
case FormattedBin of
OriginalBin -> {ok, Formatted, FormatWarnings};
_ -> {warn, FormatWarnings}
end;
Other ->
Other
end.
check_stdin(Options) ->
CurrentOpts = io:getopts(standard_io),
io:setopts(standard_io, [binary, {encoding, unicode}]),
Result =
case read_stdin([]) of
{ok, OriginalBin} ->
Original = unicode:characters_to_list(OriginalBin),
case erlfmt:format_string(Original, Options) of
{ok, Formatted, FormatWarnings} ->
case Formatted of
Original -> {ok, Formatted, FormatWarnings};
_ -> {warn, FormatWarnings}
end;
Other ->
Other
end;
{error, Reason} ->
{error, Reason}
end,
io:setopts(standard_io, CurrentOpts),
Result.
-dialyzer({no_improper_lists, [read_stdin/1]}).
read_stdin(Data) ->
case io:get_chars(standard_io, "", 4096) of
MoreData when is_binary(MoreData) -> read_stdin([Data | MoreData]);
eof -> {ok, Data};
{error, Reason} -> {error, Reason}
end.
-spec parse_opts(list()) -> parsed().
parse_opts(Args) ->
parse_opts(Args, [], [], #config{}).
parse_opts([help | _Rest], _Files, _Exclude, _Config) ->
help;
parse_opts([version | _Rest], _Files, _Exclude, _Config) ->
version;
parse_opts([write | _Rest], _Files, _Exclude, #config{out = Out}) when Out =/= standard_out ->
{error, "--write or replace mode can't be combined check mode"};
parse_opts([write | Rest], Files, Exclude, Config) ->
parse_opts(Rest, Files, Exclude, Config#config{out = replace});
parse_opts([{out, _Path} | _Rest], _Files, _Exclude, #config{out = Out}) when
Out =/= standard_out
->
{error, "out or replace mode can't be combined check mode"};
parse_opts([{out, Path} | Rest], Files, Exclude, Config) ->
parse_opts(Rest, Files, Exclude, Config#config{out = {path, Path}});
parse_opts([verbose | Rest], Files, Exclude, Config) ->
parse_opts(Rest, Files, Exclude, Config#config{verbose = true});
parse_opts([check | _Rest], _Files, _Exclude, #config{out = Out}) when Out =/= standard_out ->
{error, "--check mode can't be combined write or replace mode"};
parse_opts([check | Rest], Files, Exclude, Config) ->
parse_opts(Rest, Files, Exclude, Config#config{out = check});
parse_opts([{print_width, Value} | Rest], Files, Exclude, Config) ->
parse_opts(Rest, Files, Exclude, Config#config{print_width = Value});
parse_opts([require_pragma | _Rest], _Files, _Exclude, #config{pragma = insert}) ->
{error, "Cannot use both --insert-pragma and --require-pragma options together."};
parse_opts([require_pragma | _Rest], _Files, _Exclude, #config{pragma = delete}) ->
{error, "Cannot use both --delete-pragma and --require-pragma options together."};
parse_opts([require_pragma | Rest], Files, Exclude, Config) ->
parse_opts(Rest, Files, Exclude, Config#config{pragma = require});
parse_opts([insert_pragma | _Rest], _Files, _Exclude, #config{pragma = require}) ->
{error, "Cannot use both --insert-pragma and --require-pragma options together."};
parse_opts([insert_pragma | _Rest], _Files, _Exclude, #config{pragma = delete}) ->
{error, "Cannot use both --insert-pragma and --delete-pragma options together."};
parse_opts([insert_pragma | Rest], Files, Exclude, Config) ->
parse_opts(Rest, Files, Exclude, Config#config{pragma = insert});
parse_opts([delete_pragma | _Rest], _Files, _Exclude, #config{pragma = insert}) ->
{error, "Cannot use both --insert-pragma and --delete-pragma options together."};
parse_opts([delete_pragma | _Rest], _Files, _Exclude, #config{pragma = require}) ->
{error, "Cannot use both --require-pragma and --delete-pragma options together."};
parse_opts([delete_pragma | Rest], Files, Exclude, Config) ->
parse_opts(Rest, Files, Exclude, Config#config{pragma = delete});
parse_opts([{range, String} | Rest], Files, Exclude, Config) ->
% Ad-hoc parsing. Mitigation for the absence of "couple of integers" direct support.
Range1 = string:split(String, ",", all),
Range2 = [list_to_integer(X) || X <- Range1],
Range3 =
case Range2 of
[X] ->
% Single line. Set end=start
[X, X];
[X, Y] ->
[X, Y];
_ ->
{error, "Range: Expected 1 argument (single line) or 2 (start, end)."}
end,
case Range3 of
[Start, End] when Start > End ->
{error, "Range: End must be greater or equal than Start."};
[Start, End] ->
parse_opts(Rest, Files, Exclude, Config#config{range = {Start, End}});
Error ->
Error
end;
parse_opts([{files, NewFiles} | Rest], Files, Exclude, Config) ->
parse_opts(Rest, expand_files(NewFiles, Files), Exclude, Config);
parse_opts([{exclude_files, NewExcludes} | Rest], Files, Exclude, Config) ->
parse_opts(Rest, Files, expand_files(NewExcludes, Exclude), Config);
parse_opts([], [stdin], _Exclude, #config{out = Out}) when Out =/= standard_out, Out =/= check ->
{error, "stdin mode can't be combined with write options"};
parse_opts([], [stdin], [], Config) ->
{format, [stdin], [], Config};
parse_opts([], [stdin], [_Exclude | _], _Config) ->
{error, "stdin mode can't be combined with excluded files"};
parse_opts([], Files, Exclude, Config) ->
case lists:member(stdin, Files) of
true -> {error, "stdin mode can't be combined with other files"};
false -> {format, lists:reverse(Files), lists:reverse(Exclude), Config}
end;
parse_opts([Unknown | _], _Files, _Exclude, _Config) ->
{error, io_lib:format("unknown option: ~p", [Unknown])}.
-spec resolve_parsed(parsed(), parsed()) -> parsed().
resolve_parsed(PreferParsed, DefaultParsed) ->
case {PreferParsed, DefaultParsed} of
{{error, _} = Error, _} ->
Error;
{_, {error, _} = Error} ->
Error;
{help, _} ->
help;
{_, help} ->
help;
{version, _} ->
version;
{_, version} ->
version;
{
{format, PreferFiles, PreferExclude, PreferConfig},
{format, DefaultFiles, DefaultExclude, DefaultConfig}
} ->
{format, resolve_files(PreferFiles, DefaultFiles),
resolve_files(PreferExclude, DefaultExclude),
resolve_config(PreferConfig, DefaultConfig)}
end.
resolve_files([], DefaultFiles) -> DefaultFiles;
resolve_files(PreferFiles, _DefaultFiles) -> PreferFiles.
resolve_config(
#config{
verbose = PreferVerbose,
print_width = PreferWidth,
pragma = PreferPragma,
out = PreferOut,
range = PreferRange
},
#config{
verbose = DefaultVerbose,
print_width = DefaultWidth,
pragma = DefaultPragma,
out = DefaultOut,
range = DefaultRange
}
) ->
#config{
verbose = PreferVerbose orelse DefaultVerbose,
print_width = resolve_width(PreferWidth, DefaultWidth),
pragma = resolve_pragma(PreferPragma, DefaultPragma),
out = resolve_out(PreferOut, DefaultOut),
range = resolve_range(PreferRange, DefaultRange)
}.
resolve_width(undefined, W) -> W;
resolve_width(W, _) -> W.
resolve_pragma(ignore, P) -> P;
resolve_pragma(P, _) -> P.
resolve_out(standard_out, O) -> O;
resolve_out(O, _) -> O.
resolve_range(undefined, R) -> R;
resolve_range(R, _) -> R.
specified_files(List) ->
lists:filter(
fun
({files, _}) -> true;
(stdin) -> true;
(_) -> false
end,
List
).
expand_files("-", Files) ->
[stdin | Files];
expand_files(NewFile, Files) when is_integer(hd(NewFile)) ->
case filelib:is_regular(NewFile) of
true ->
[NewFile | Files];
false ->
case filelib:wildcard(NewFile) of
[] ->
Files;
NewFiles ->
NewFiles ++ Files
end
end;
expand_files(NewFiles, Files) when is_list(NewFiles) ->
lists:foldl(fun expand_files/2, Files, NewFiles).
print_error_info(Info) ->
io:put_chars(standard_error, [erlfmt:format_error_info(Info), $\n]).
parallel(Fun, List) ->
N = erlang:system_info(schedulers) * 2,
parallel_loop(Fun, List, N, [], _ReducedResult = ok).
parallel_loop(_, [], _, [], ReducedResult) ->
ReducedResult;
parallel_loop(Fun, [Elem | Rest], N, Refs, ReducedResult) when length(Refs) < N ->
{_, Ref} = erlang:spawn_monitor(fun() -> exit(Fun(Elem)) end),
parallel_loop(Fun, Rest, N, [Ref | Refs], ReducedResult);
parallel_loop(Fun, List, N, Refs0, ReducedResult0) ->
receive
{'DOWN', Ref, process, _, Result} when
Result =:= error; Result =:= warn; Result =:= ok; Result =:= skip
->
Refs = Refs0 -- [Ref],
ReducedResult =
case {Result, ReducedResult0} of
{_, error} -> error;
{error, _} -> error;
{_, warn} -> warn;
{warn, _} -> warn;
{_, _} -> ok
end,
parallel_loop(Fun, List, N, Refs, ReducedResult);
{'DOWN', _Ref, process, _, Crash} ->
exit(Crash)
end.