%%
%% %CopyrightBegin%
%%
%% Copyright Ericsson AB 1996-2015. All Rights Reserved.
%%
%% The contents of this file are subject to the Erlang Public License,
%% Version 1.1, (the "License"); you may not use this file except in
%% compliance with the License. You should have received a copy of the
%% Erlang Public License along with this software. If not, it can be
%% retrieved online at http://www.erlang.org/.
%%
%% Software distributed under the License is distributed on an "AS IS"
%% basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See
%% the License for the specific language governing rights and limitations
%% under the License.
%%
%% %CopyrightEnd%
%% @doc EDoc interface to Erlang specifications and types.
-module(edoc_specs).
-export([type/2, spec/2, dummy_spec/1, docs/2]).
-export([add_data/4, tag/1, is_tag/1]).
-include("edoc.hrl").
-include("edoc_types.hrl").
-type syntaxTree() :: erl_syntax:syntaxTree().
-define(TOP_TYPE, term).
%%
%% Exported functions
%%
-spec type(Form::syntaxTree(), TypeDocs::dict:dict()) -> #tag{}.
%% @doc Convert an Erlang type to EDoc representation.
%% TypeDocs is a dict of {Name, Doc}.
%% Note: #t_typedef.name is set to {record, R} for record types.
type(Form, TypeDocs) ->
{Name, Data0} = erl_syntax_lib:analyze_wild_attribute(Form),
type = tag(Name),
{TypeName, Type, Args, Doc} =
case Data0 of
{{record, R}, Fs, []} ->
L = erl_syntax:get_pos(Form),
{{record, R}, {type, L, record, [{atom,L,R} | Fs]}, [], ""};
{N,T,As} ->
Doc0 =
case dict:find({N, length(As)}, TypeDocs) of
{ok, Doc1} ->
Doc1;
error ->
""
end,
{#t_name{name = N}, T, As, Doc0}
end,
#tag{name = type, line = get_line(element(2, Type)),
origin = code,
data = {#t_typedef{name = TypeName,
args = d2e(Args),
type = d2e(opaque2abstr(Name, Type))},
Doc}}.
-spec spec(Form::syntaxTree(), ClauseN::pos_integer()) -> #tag{}.
%% @doc Convert an Erlang spec to EDoc representation.
spec(Form, Clause) ->
{Name, _Arity, TypeSpecs} = get_spec(Form),
TypeSpec = lists:nth(Clause, TypeSpecs),
#tag{name = spec, line = get_line(element(2, TypeSpec)),
origin = code,
data = aspec(d2e(TypeSpec), Name)}.
-spec dummy_spec(Form::syntaxTree()) -> #tag{}.
%% @doc Create a #tag{} record where data is a string with the name of
%% the given Erlang spec and an empty list of arguments.
dummy_spec(Form) ->
{#t_name{name = Name}, Arity, TypeSpecs} = get_spec(Form),
As = string:join(lists:duplicate(Arity, "_X"), ","),
S = lists:flatten(io_lib:format("~p(~s) -> true\n", [Name, As])),
#tag{name = spec, line = get_line(element(2, hd(TypeSpecs))),
origin = code, data = S}.
-spec docs(Forms::[syntaxTree()],
CommentFun :: fun( ([syntaxTree()], Line :: term()) -> #tag{} ))
-> dict:dict().
%% @doc Find comments after -type/-opaque declarations.
%% Postcomments "inside" the type are skipped.
docs(Forms, CommentFun) ->
find_type_docs(Forms, [], CommentFun).
-type entry() :: #entry{}.
-type module_info() :: #module{}.
-type entries() :: [entry()].
-spec add_data(Entries::entries(), Options::proplists:proplist(),
File::file:filename(), Module::module_info()) -> entries().
%% @doc Create tags a la EDoc for Erlang specifications and types.
%% Exported types and types used (indirectly) by Erlang specs are
%% added to the entries.
add_data(Entries, Opts, File, Module) ->
TypeDefs0 = espec_types(Entries),
TypeTable = ets:new(etypes, [ordered_set]),
Es1 = expand_records(Entries, TypeDefs0, TypeTable, Opts, File, Module),
Es = [use_tags(E, TypeTable) || E <- Es1],
true = ets:delete(TypeTable),
Es.
%%
%% Local functions
%%
aspec(#t_spec{}=Spec, Name) ->
Spec#t_spec{name = Name};
aspec(Type, Name) ->
#t_spec{name = Name, type = Type}.
get_spec(Form) ->
{spec, Data0} = erl_syntax_lib:analyze_wild_attribute(Form),
case Data0 of
{{F,A}, D} ->
{#t_name{name = F}, A, D};
{{M,F,A}, D} ->
{#t_name{module = M, name = F}, A, D}
end.
find_type_docs([], Cs, _Fun) ->
dict:from_list(Cs);
find_type_docs([F | Fs], Cs, Fun) ->
try get_name_and_last_line(F) of
{Name, LastTypeLine} ->
C0 = erl_syntax:comment(["% @type f(). "]),
C1 = erl_syntax:set_pos(C0, LastTypeLine),
%% Postcomments before the dot after the typespec are ignored.
C2 = [C1 | [C ||
C <- erl_syntax:get_postcomments(F),
erl_syntax:get_pos(C) >= LastTypeLine]],
C3 = collect_comments(Fs, LastTypeLine),
#tag{data = Doc0} = Fun(lists:reverse(C2 ++ C3), LastTypeLine),
case strip(Doc0) of % Strip away "f(). \n"
"" ->
find_type_docs(Fs, Cs, Fun);
Doc ->
W = edoc_wiki:parse_xml(Doc, LastTypeLine),
find_type_docs(Fs, [{Name, W}|Cs], Fun)
end
catch _:_ ->
find_type_docs(Fs, Cs, Fun)
end.
collect_comments([], _Line) ->
[];
collect_comments([F | Fs], Line) ->
L1 = erl_syntax:get_pos(F),
if
L1 =:= Line + 1;
L1 =:= Line -> % a separate postcomment
case is_comment(F) of
true ->
[F | collect_comments(Fs, L1)];
false ->
[]
end;
true ->
[]
end.
%% Note: there is a creepy bug concerning an include file terminated
%% by a -type attribute and the include statement is followed by a
%% comment (which is not meant to be documentation of the type).
is_comment(F) ->
erl_syntax_lib:analyze_form(F) =:= comment.
strip("") ->
"";
strip([$\n | S]) ->
S;
strip([_ | S]) ->
strip(S).
%% Find the type name and the greatest line number of a type spec.
%% Should use syntax_tools but this has to do for now.
get_name_and_last_line(F) ->
{Name, Data} = erl_syntax_lib:analyze_wild_attribute(F),
type = edoc_specs:tag(Name),
Attr = {attribute, erl_syntax:get_pos(F), Name, Data},
Fun = fun(A) ->
Line = get_line(A),
case get('$max_line') of
Max when Max < Line ->
_ = put('$max_line', Line);
_ ->
ok
end
end,
undefined = put('$max_line', 0),
_ = erl_parse:map_anno(Fun, Attr),
Line = erase('$max_line'),
TypeName = case Data of
{N, _T, As} when is_atom(N) -> % skip records
{N, length(As)}
end,
{TypeName, Line}.
get_line(Anno) ->
erl_anno:line(Anno).
%% Collect all Erlang types. Types in comments (@type) shadow Erlang
%% types (-spec/-opaque).
espec_types(Entries) ->
Tags = get_all_tags(Entries),
CommTs = [type_name(T) ||
#tag{name = type, origin = comment}=T <- Tags],
CT = sets:from_list(CommTs),
[T || #tag{name = Name, origin = code}=T <- Tags,
tag(Name) =:= type,
not sets:is_element(type_name(T), CT)].
get_all_tags(Es) ->
lists:flatmap(fun (#entry{data = Ts}) -> Ts end, Es).
%% Turns an opaque type into an abstract datatype.
%% Note: top level annotation is ignored.
opaque2abstr(opaque, _T) -> undefined;
opaque2abstr(type, T) -> T.
%% Replaces the parameters extracted from the source (by
%% edoc_extract:parameters/1) by annotations and variable names, using
%% the source parameters as default values
%% Selects seen types (exported types, types used by specs),
%% skips records and unused types.
use_tags(#entry{data = Ts}=E, TypeTable) ->
use_tags(Ts, E, TypeTable, []).
use_tags([], E, _TypeTable, NTs) ->
E#entry{data = lists:reverse(NTs)};
use_tags([#tag{origin = code}=T | Ts], E, TypeTable, NTs) ->
case tag(T#tag.name) of
spec ->
Args = params(T, E#entry.args),
use_tags(Ts, E#entry{args = Args}, TypeTable, [T | NTs]);
type ->
TypeName = type_name(T),
case ets:lookup(TypeTable, TypeName) of
[{{{record,_},_},_,_}] ->
use_tags(Ts, E, TypeTable, NTs);
[{_,_,not_seen}] ->
use_tags(Ts, E, TypeTable, NTs);
[] ->
use_tags(Ts, E, TypeTable, NTs);
[{TypeName, Tag, seen}] ->
use_tags(Ts, E, TypeTable, [Tag | NTs])
end
end;
use_tags([T | Ts], E, TypeTable, NTs) ->
use_tags(Ts, E, TypeTable, [T | NTs]).
params(#tag{name = spec, data=#t_spec{type = #t_fun{args = As}}}, Default) ->
parms(As, Default).
parms([], []) ->
[];
parms([A | As], [D | Ds]) ->
[param(A, D) | parms(As, Ds)].
param(#t_list{type = Type}, Default) ->
param(Type, Default);
param(#t_paren{type = Type}, Default) ->
param(Type, Default);
param(#t_nonempty_list{type = Type}, Default) ->
param(Type, Default);
param(#t_record{name = #t_atom{val = Name}}, _Default) ->
list_to_atom(capitalize(atom_to_list(Name)));
param(T, Default) ->
arg_name(?t_ann(T), Default).
capitalize([C | Cs]) when C >= $a, C =< $z -> [C - 32 | Cs];
capitalize(Cs) -> Cs.
%% Like edoc_types:arg_name/1
arg_name([], Default) ->
Default;
arg_name([A | As], Default) ->
case is_name(A) of
true -> A;
false -> arg_name(As, Default)
end.
is_name(A) ->
is_atom(A).
d2e(T) ->
d2e(T, 0).
d2e({ann_type,_,[V, T0]}, Prec) ->
%% Note: the -spec/-type syntax allows annotations everywhere, but
%% EDoc does not. The fact that the annotation is added to the
%% type here does not necessarily mean that it will be used by the
%% layout module.
{_L,P,R} = erl_parse:type_inop_prec('::'),
T1 = d2e(T0, R),
T = ?add_t_ann(T1, element(3, V)),
maybe_paren(P, Prec, T); % the only necessary call to maybe_paren()
d2e({remote_type,_,[{atom,_,M},{atom,_,F},Ts0]}, _Prec) ->
Ts = d2e(Ts0),
typevar_anno(#t_type{name = #t_name{module = M, name = F}, args = Ts}, Ts);
d2e({type,_,'fun',[{type,_,product,As0},Ran0]}, _Prec) ->
Ts = [Ran|As] = d2e([Ran0|As0]),
%% Assume that the linter has checked type variables.
typevar_anno(#t_fun{args = As, range = Ran}, Ts);
d2e({type,_,'fun',[A0={type,_,any},Ran0]}, _Prec) ->
Ts = [A, Ran] = d2e([A0, Ran0]),
typevar_anno(#t_fun{args = [A], range = Ran}, Ts);
d2e({type,_,'fun',[]}, _Prec) ->
#t_type{name = #t_name{name = function}, args = []};
d2e({type,_,any}, _Prec) ->
#t_var{name = '...'}; % Kludge... not a type variable!
d2e({type,_,nil,[]}, _Prec) ->
#t_nil{};
d2e({paren_type,_,[T]}, Prec) ->
d2e(T, Prec);
d2e({type,_,list,[T0]}, _Prec) ->
T = d2e(T0),
typevar_anno(#t_list{type = T}, [T]);
d2e({type,_,nonempty_list,[T0]}, _Prec) ->
T = d2e(T0),
typevar_anno(#t_nonempty_list{type = T}, [T]);
d2e({type,_,bounded_fun,[T,Gs]}, _Prec) ->
[F0|Defs] = d2e([T|Gs]),
F = ?set_t_ann(F0, lists:keydelete(type_variables, 1, ?t_ann(F0))),
%% Assume that the linter has checked type variables.
#t_spec{type = typevar_anno(F, [F0]), defs = Defs};
d2e({type,_,range,[V1,V2]}, Prec) ->
{_L,P,_R} = erl_parse:type_inop_prec('..'),
{integer,_,I1} = erl_eval:partial_eval(V1),
{integer,_,I2} = erl_eval:partial_eval(V2),
T0 = #t_integer_range{from = I1, to = I2},
maybe_paren(P, Prec, T0);
d2e({type,_,constraint,[Sub,Ts0]}, _Prec) ->
case {Sub,Ts0} of
{{atom,_,is_subtype},[{var,_,N},T0]} ->
Ts = [T] = d2e([T0]),
#t_def{name = #t_var{name = N}, type = typevar_anno(T, Ts)};
{{atom,_,is_subtype},[ST0,T0]} ->
%% Should not happen.
Ts = [ST,T] = d2e([ST0,T0]),
#t_def{name = ST, type = typevar_anno(T, Ts)};
_ ->
throw_error(get_line(element(2, Sub)), "cannot handle guard", [])
end;
d2e({type,_,union,Ts0}, Prec) ->
{_L,P,R} = erl_parse:type_inop_prec('|'),
Ts = d2e(Ts0, R),
T = maybe_paren(P, Prec, #t_union{types = Ts}),
typevar_anno(T, Ts);
d2e({type,_,tuple,any}, _Prec) ->
#t_type{name = #t_name{name = tuple}, args = []};
d2e({type,_,binary,[Base,Unit]}, _Prec) ->
{integer,_,B} = erl_eval:partial_eval(Base),
{integer,_,U} = erl_eval:partial_eval(Unit),
#t_binary{base_size = B, unit_size = U};
d2e({type,_,map,any}, _Prec) ->
#t_map{types = []};
d2e({type,_,map,Es}, _Prec) ->
#t_map{types = d2e(Es) };
d2e({type,_,map_field_assoc,[K,V]}, Prec) ->
T = #t_map_field{k_type = d2e(K), v_type=d2e(V) },
{P,_R} = erl_parse:type_preop_prec('#'),
maybe_paren(P, Prec, T);
d2e({type,_,map_field_exact,K,V}, Prec) ->
T = #t_map_field{k_type = d2e(K), v_type=d2e(V) },
{P,_R} = erl_parse:type_preop_prec('#'),
maybe_paren(P, Prec, T);
d2e({type,_,tuple,Ts0}, _Prec) ->
Ts = d2e(Ts0),
typevar_anno(#t_tuple{types = Ts}, Ts);
d2e({type,_,record,[Name|Fs0]}, Prec) ->
Atom = #t_atom{val = element(3, Name)},
Fs = d2e(Fs0),
{P,_R} = erl_parse:type_preop_prec('#'),
T = maybe_paren(P, Prec, #t_record{name = Atom, fields = Fs}),
typevar_anno(T, Fs);
d2e({type,_,field_type,[Name,Type0]}, Prec) ->
{_L,P,R} = erl_parse:type_inop_prec('::'),
Type = maybe_paren(P, Prec, d2e(Type0, R)),
T = #t_field{name = #t_atom{val = element(3, Name)}, type = Type},
typevar_anno(T, [Type]);
d2e({typed_record_field,{record_field,L,Name},Type}, Prec) ->
d2e({type,L,field_type,[Name,Type]}, Prec);
d2e({typed_record_field,{record_field,L,Name,_E},Type}, Prec) ->
d2e({type,L,field_type,[Name,Type]}, Prec);
d2e({record_field,L,_Name,_E}=F, Prec) ->
d2e({typed_record_field,F,{type,L,any,[]}}, Prec); % Maybe skip...
d2e({record_field,L,_Name}=F, Prec) ->
d2e({typed_record_field,F,{type,L,any,[]}}, Prec); % Maybe skip...
d2e({type,_,Name,Types0}, _Prec) ->
Types = d2e(Types0),
typevar_anno(#t_type{name = #t_name{name = Name}, args = Types}, Types);
d2e({user_type,_,Name,Types0}, _Prec) ->
Types = d2e(Types0),
typevar_anno(#t_type{name = #t_name{name = Name}, args = Types}, Types);
d2e({var,_,'_'}, _Prec) ->
#t_type{name = #t_name{name = ?TOP_TYPE}};
d2e({var,_,TypeName}, _Prec) ->
TypeVar = ordsets:from_list([TypeName]),
T = #t_var{name = TypeName},
%% Annotate type variables with the name of the variable.
%% Doing so will stop edoc_layout (and possibly other layout modules)
%% from using the argument name from the source or to invent a new name.
T1 = ?add_t_ann(T, {type_variables, TypeVar}),
?add_t_ann(T1, TypeName);
d2e(L, Prec) when is_list(L) ->
[d2e(T, Prec) || T <- L];
d2e({atom,_,A}, _Prec) ->
#t_atom{val = A};
d2e(undefined = U, _Prec) -> % opaque
U;
d2e(Expr, _Prec) ->
{integer,_,I} = erl_eval:partial_eval(Expr),
#t_integer{val = I}.
%% A type annotation (a tuple; neither an atom nor a list).
typevar_anno(Type, Ts) ->
Vs = typevars(Ts),
case ordsets:to_list(Vs) of
[] -> Type;
_ -> ?add_t_ann(Type, {type_variables, Vs})
end.
typevars(Ts) ->
ordsets:union(get_typevars(Ts)).
get_typevars(Ts) ->
[Vs || T <- Ts, T =/= undefined, {type_variables, Vs} <- ?t_ann(T)].
maybe_paren(P, Prec, T) when P < Prec ->
#t_paren{type = T};
maybe_paren(_P, _Prec, T) ->
T.
-record(parms, {tab, warn, file, line}).
%% Expands record references. Explicitly given record fields are kept,
%% but otherwise the fields from the record definition are substituted
%% for the reference. The reason is that there are no record types.
%% It is recommended to introduce types like "r() :: r{}" and then use
%% r() everywhere. The right hand side, r{}, is expanded in order to
%% show all fields.
%% Returns updated types in the ETS table DT.
expand_records(Entries, TypeDefs, DT, Opts, File, Module) ->
TypeList = [{type_name(T), T, not_seen} || T <- TypeDefs],
true = ets:insert(DT, TypeList),
Warn = proplists:get_value(report_missing_types, Opts,
?REPORT_MISSING_TYPES) =:= true,
P = #parms{tab = DT, warn = Warn, file = File, line = 0},
ExportedTypes = [Name ||
{export_type,Ts} <- Module#module.attributes,
is_list(Ts),
{N,I} <- Ts,
ets:member(DT, Name = {#t_name{name = N}, I})],
_ = lists:foreach(fun({N,A}) -> true = seen_type(N, A, P)
end, ExportedTypes),
entries(Entries, P, Opts).
entries([E0 | Es], P, Opts) ->
E = case edoc_data:hidden_filter([E0], Opts) of
[] ->
E0;
[_] ->
E0#entry{data = specs(E0#entry.data, P)}
end,
[E | entries(Es, P, Opts)];
entries([], _P, _Opts) ->
[].
specs([#tag{line = L, name = spec, origin = code, data = Spec}=Tag0 | Tags],
P0) ->
#t_spec{type = Type0, defs = Defs0} = Spec,
P = P0#parms{line = L},
Type = xrecs(Type0, P),
Defs = xrecs(Defs0, P),
Tag = Tag0#tag{data = Spec#t_spec{type = Type, defs = Defs}},
[Tag | specs(Tags, P)];
specs([Tag | Tags], P) ->
[Tag | specs(Tags, P)];
specs([], _P) ->
[].
xrecs(#t_def{type = Type0}=T, P) ->
Type = xrecs(Type0, P),
T#t_def{type = Type};
xrecs(#t_type{name = Name, args = Args0}=T, P) ->
Args = xrecs(Args0, P),
NArgs = length(Args),
true = seen_type(Name, NArgs, P),
T#t_type{args = Args};
xrecs(#t_var{}=T, _P) ->
T;
xrecs(#t_fun{args = Args0, range = Range0}=T, P) ->
Args = xrecs(Args0, P),
Range = xrecs(Range0, P),
T#t_fun{args = Args, range = Range};
xrecs(#t_map{types = Ts0 }=T,P) ->
Ts = xrecs(Ts0, P),
T#t_map{types = Ts };
xrecs(#t_map_field{k_type=Kt, v_type=Vt}=T, P) ->
T#t_map_field{k_type=xrecs(Kt,P), v_type=xrecs(Vt,P)};
xrecs(#t_tuple{types = Types0}=T, P) ->
Types = xrecs(Types0, P),
T#t_tuple{types = Types};
xrecs(#t_list{type = Type0}=T, P) ->
Type = xrecs(Type0, P),
T#t_list{type = Type};
xrecs(#t_nil{}=T, _P) ->
T;
xrecs(#t_paren{type = Type0}=T, P) ->
Type = xrecs(Type0, P),
T#t_paren{type = Type};
xrecs(#t_nonempty_list{type = Type0}=T, P) ->
Type = xrecs(Type0, P),
T#t_nonempty_list{type = Type};
xrecs(#t_atom{}=T, _P) ->
T;
xrecs(#t_integer{}=T, _P) ->
T;
xrecs(#t_integer_range{}=T, _P) ->
T;
xrecs(#t_binary{}=T, _P) ->
T;
xrecs(#t_float{}=T, _P) ->
T;
xrecs(#t_union{types = Types0}=T, P) ->
Types = xrecs(Types0, P),
T#t_union{types = Types};
xrecs(#t_record{fields = Fields0}=T, P) ->
Fields1 = xrecs(Fields0, P),
#t_record{name = #t_atom{val = Name}} = T,
RName = {record, Name},
true = seen_type(RName, 0, P),
Fields = select_fields(Fields1, RName, P#parms.tab),
T#t_record{fields = Fields};
xrecs(#t_field{type = Type0}=T, P) ->
Type = xrecs(Type0, P),
T#t_field{type = Type};
xrecs(undefined=T, _P) -> % opaque
T;
xrecs([]=T, _P) ->
T;
xrecs([E0 | Es0], P) ->
[xrecs(E0, P) | xrecs(Es0, P)].
seen_type(N, NArgs, P) ->
TypeName = {N, NArgs},
#parms{tab = DT} = P,
case {ets:lookup(DT, TypeName), N} of
{[{TypeName, _, seen}], _} ->
true;
{[{TypeName, TagType, not_seen}], _} when N#t_name.module =:= [] ->
expand_datatype(TagType, proper_type, DT, P);
{[{TypeName, TagType, not_seen}], {record, _}} ->
expand_datatype(TagType, record_type, DT, P);
{[], {record, R}} ->
#parms{warn = W, line = L, file = File} = P,
[edoc_report:warning(L, File, "reference to untyped record ~w",
[R]) || W],
ets:insert(DT, {TypeName, fake, seen});
{[], _} -> % External type or missing type.
true
end.
expand_datatype(Tag0, Kind, DT, P0) ->
#tag{line = L, data = {T0, Doc}} = Tag0,
#t_typedef{type = Type0, defs = []} = T0,
TypeName = type_name(Tag0),
true = ets:update_element(DT, TypeName, {3, seen}),
P = P0#parms{line = L},
Type = case Kind of
record_type ->
#t_record{fields = Fields0} = Type0,
Fields = xrecs(Fields0, P),
Type0#t_record{fields = Fields};
proper_type ->
xrecs(Type0, P)
end,
Tag = Tag0#tag{data={T0#t_typedef{type=Type}, Doc}},
ets:insert(DT, {TypeName, Tag, seen}).
select_fields(Fields, Name, DT) ->
RecordName = {Name, 0},
case ets:lookup(DT, RecordName) of
[{RecordName, fake, seen}] ->
Fields;
[{RecordName, #tag{data = {T, _Doc}}, seen}] ->
#t_typedef{args = [], type = #t_record{fields = Fs}, defs = []}=T,
[find_field(F, Fields) || F <- Fs]
end.
find_field(F, Fs) ->
case lists:keyfind(F#t_field.name, #t_field.name, Fs) of
false -> F;
NF -> NF
end.
type_name(#tag{name = type,
data = {#t_typedef{name = Name, args = As},_}}) ->
{Name, length(As)}.
%% @doc Return `true' if `Tag' is one of the specification and type
%% attribute tags recognized by the Erlang compiler.
-spec is_tag(Tag::atom()) -> boolean().
is_tag(opaque) -> true;
is_tag(spec) -> true;
is_tag(type) -> true;
is_tag(_) -> false.
%% @doc Return the kind of the attribute tag.
-type tag_kind() :: 'type' | 'spec' | 'unknown'.
-spec tag(Tag::atom()) -> tag_kind().
tag(opaque) -> type;
tag(spec) -> spec;
tag(type) -> type;
tag(_) -> unknown.
throw_error(Line, S, A) ->
edoc_report:error(Line, "", io_lib:format(S, A)),
throw(error).