From 05a8477be7e25993c51076d85b5660112f3859d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kan=20Nilsson?= Date: Wed, 2 Oct 2024 08:38:32 +0200 Subject: [PATCH] Support OTP 27 style docs (#1556) --- apps/els_lsp/src/els_docs.erl | 170 ++++++++++++++++----- apps/els_lsp/src/els_eep48_docs.erl | 63 +++++--- apps/els_lsp/test/els_completion_SUITE.erl | 38 +++-- apps/els_lsp/test/els_hover_SUITE.erl | 26 ++-- 4 files changed, 216 insertions(+), 81 deletions(-) diff --git a/apps/els_lsp/src/els_docs.erl b/apps/els_lsp/src/els_docs.erl index 3c3b53294..83451e829 100644 --- a/apps/els_lsp/src/els_docs.erl +++ b/apps/els_lsp/src/els_docs.erl @@ -18,13 +18,10 @@ -include("els_lsp.hrl"). -include_lib("kernel/include/logger.hrl"). --ifdef(OTP_RELEASE). --if(?OTP_RELEASE >= 23). -include_lib("kernel/include/eep48.hrl"). -export([eep48_docs/4]). +-export([eep59_docs/4]). -type docs_v1() :: #docs_v1{}. --endif. --endif. %%============================================================================== %% Macro Definitions @@ -135,23 +132,28 @@ function_docs(Type, M, F, A, true = _DocsMemo) -> end; function_docs(Type, M, F, A, false = _DocsMemo) -> %% call via ?MODULE to enable mocking in tests - case ?MODULE:eep48_docs(function, M, F, A) of + case ?MODULE:eep59_docs(function, M, F, A) of {ok, Docs} -> [{text, Docs}]; {error, not_available} -> - %% We cannot fetch the EEP-48 style docs, so instead we create - %% something similar using the tools we have. - Sig = {h2, signature(Type, M, F, A)}, - L = [ - function_clauses(M, F, A), - specs(M, F, A), - edoc(M, F, A) - ], - case lists:append(L) of - [] -> - [Sig]; - Docs -> - [Sig, {text, "---"} | Docs] + case ?MODULE:eep48_docs(function, M, F, A) of + {ok, Docs} -> + [{text, Docs}]; + {error, not_available} -> + %% We cannot fetch the EEP-48 style docs, so instead we create + %% something similar using the tools we have. + Sig = {h2, signature(Type, M, F, A)}, + L = [ + function_clauses(M, F, A), + specs(M, F, A), + edoc(M, F, A) + ], + case lists:append(L) of + [] -> + [Sig]; + Docs -> + [Sig, {text, "---"} | Docs] + end end end. @@ -207,36 +209,24 @@ signature('remote', M, F, A) -> %% If it is not available it tries to create the EEP-48 style docs %% using edoc. -ifdef(NATIVE_FORMAT). +-define(MARKDOWN_FORMAT, <<"text/markdown">>). + -spec eep48_docs(function | type, atom(), atom(), non_neg_integer()) -> {ok, string()} | {error, not_available}. eep48_docs(Type, M, F, A) -> - Render = - case Type of - function -> - render; - type -> - render_type - end, GL = setup_group_leader_proxy(), try get_doc_chunk(M) of {ok, #docs_v1{ - format = ?NATIVE_FORMAT, + format = Format, module_doc = MDoc - } = DocChunk} when MDoc =/= hidden -> + } = DocChunk} when + MDoc =/= hidden, + (Format == ?MARKDOWN_FORMAT orelse + Format == ?NATIVE_FORMAT) + -> flush_group_leader_proxy(GL), - - case els_eep48_docs:Render(M, F, A, DocChunk) of - {error, _R0} -> - case els_eep48_docs:Render(M, F, DocChunk) of - {error, _R1} -> - {error, not_available}; - Docs -> - {ok, els_utils:to_list(Docs)} - end; - Docs -> - {ok, els_utils:to_list(Docs)} - end; + render_doc(Type, M, F, A, DocChunk); _R1 -> ?LOG_DEBUG(#{error => _R1}), {error, not_available} @@ -255,6 +245,108 @@ eep48_docs(Type, M, F, A) -> {error, not_available} end. +-spec eep59_docs(function | type, atom(), atom(), non_neg_integer()) -> + {ok, string()} | {error, not_available}. +eep59_docs(Type, M, F, A) -> + try get_doc(M) of + {ok, + #docs_v1{ + format = Format, + module_doc = MDoc + } = DocChunk} when + MDoc =/= hidden, + (Format == ?MARKDOWN_FORMAT orelse + Format == ?NATIVE_FORMAT) + -> + render_doc(Type, M, F, A, DocChunk); + _R1 -> + ?LOG_DEBUG(#{error => _R1}), + {error, not_available} + catch + C:E:ST -> + %% code:get_doc/1 fails for escriptized modules, so fall back + %% reading docs from source. See #751 for details + ?LOG_DEBUG(#{ + slogan => "Error fetching docs, falling back to src.", + module => M, + error => {C, E}, + st => ST + }), + {error, not_available} + end. + +-spec get_doc(module()) -> {ok, docs_v1()} | {error, not_available}. +get_doc(Module) when is_atom(Module) -> + %% This will error if module isn't loaded + try code:get_doc(Module) of + {ok, DocChunk} -> + {ok, DocChunk}; + {error, _} -> + %% If the module isn't loaded, we try + %% to find the doc chunks from any .beam files + %% matching the module name. + Beams = find_beams(Module), + get_doc(Beams, Module) + catch + C:E:ST -> + %% code:get_doc/1 fails for escriptized modules, so fall back + %% reading docs from source. See #751 for details + ?LOG_INFO(#{ + slogan => "Error fetching docs, falling back to src.", + module => Module, + error => {C, E}, + st => ST + }), + {error, not_available} + end. + +-spec get_doc([file:filename()], module()) -> + {ok, docs_v1()} | {error, not_available}. +get_doc([], _Module) -> + {error, not_available}; +get_doc([Beam | T], Module) -> + case beam_lib:chunks(Beam, ["Docs"]) of + {ok, {Module, [{"Docs", Bin}]}} -> + {ok, binary_to_term(Bin)}; + _ -> + get_doc(T, Module) + end. + +-spec find_beams(module()) -> [file:filename()]. +find_beams(Module) -> + %% Look for matching .beam files under the project root + RootUri = els_config:get(root_uri), + Root = binary_to_list(els_uri:path(RootUri)), + Beams0 = filelib:wildcard( + filename:join([Root, "**", atom_to_list(Module) ++ ".beam"]) + ), + %% Sort the beams, to ensure we try the newest beam first + TimeBeams = [{filelib:last_modified(Beam), Beam} || Beam <- Beams0], + {_, Beams} = lists:unzip(lists:reverse(lists:sort(TimeBeams))), + Beams. + +-spec render_doc(function | type, module(), atom(), arity(), docs_v1()) -> + {ok, string()} | {error, not_available}. +render_doc(Type, M, F, A, DocChunk) -> + Render = + case Type of + function -> + render; + type -> + render_type + end, + case els_eep48_docs:Render(M, F, A, DocChunk) of + {error, _R0} -> + case els_eep48_docs:Render(M, F, DocChunk) of + {error, _R1} -> + {error, not_available}; + Docs -> + {ok, els_utils:to_list(Docs)} + end; + Docs -> + {ok, els_utils:to_list(Docs)} + end. + %% This function first tries to read the doc chunk from the .beam file %% and if that fails it attempts to find the .chunk file. -spec get_doc_chunk(M :: module()) -> {ok, term()} | error. diff --git a/apps/els_lsp/src/els_eep48_docs.erl b/apps/els_lsp/src/els_eep48_docs.erl index 66d7ac132..a2b8c4884 100644 --- a/apps/els_lsp/src/els_eep48_docs.erl +++ b/apps/els_lsp/src/els_eep48_docs.erl @@ -157,6 +157,7 @@ render(Module, Function, #docs_v1{docs = Docs} = D, Config) when Docs ), D, + Module, Config ); render(_Module, Function, Arity, #docs_v1{} = D) -> @@ -183,6 +184,7 @@ render(Module, Function, Arity, #docs_v1{docs = Docs} = D, Config) when Docs ), D, + Module, Config ). @@ -210,7 +212,7 @@ render_type(Module, Type, D = #docs_v1{}) -> Arity :: arity(), Docs :: docs_v1(), Res :: unicode:chardata() | {error, type_missing}. -render_type(_Module, Type, #docs_v1{docs = Docs} = D, Config) -> +render_type(Module, Type, #docs_v1{docs = Docs} = D, Config) -> render_typecb_docs( lists:filter( fun @@ -222,6 +224,7 @@ render_type(_Module, Type, #docs_v1{docs = Docs} = D, Config) -> Docs ), D, + Module, Config ); render_type(_Module, Type, Arity, #docs_v1{} = D) -> @@ -234,7 +237,7 @@ render_type(_Module, Type, Arity, #docs_v1{} = D) -> Docs :: docs_v1(), Config :: config(), Res :: unicode:chardata() | {error, type_missing}. -render_type(_Module, Type, Arity, #docs_v1{docs = Docs} = D, Config) -> +render_type(Module, Type, Arity, #docs_v1{docs = Docs} = D, Config) -> render_typecb_docs( lists:filter( fun @@ -246,6 +249,7 @@ render_type(_Module, Type, Arity, #docs_v1{docs = Docs} = D, Config) -> Docs ), D, + Module, Config ). @@ -275,7 +279,7 @@ render_callback(_Module, Callback, #docs_v1{} = D) -> Res :: unicode:chardata() | {error, callback_missing}. render_callback(_Module, Callback, Arity, #docs_v1{} = D) -> render_callback(_Module, Callback, Arity, D, #{}); -render_callback(_Module, Callback, #docs_v1{docs = Docs} = D, Config) -> +render_callback(Module, Callback, #docs_v1{docs = Docs} = D, Config) -> render_typecb_docs( lists:filter( fun @@ -287,6 +291,7 @@ render_callback(_Module, Callback, #docs_v1{docs = Docs} = D, Config) -> Docs ), D, + Module, Config ). @@ -297,7 +302,7 @@ render_callback(_Module, Callback, #docs_v1{docs = Docs} = D, Config) -> Docs :: docs_v1(), Config :: config(), Res :: unicode:chardata() | {error, callback_missing}. -render_callback(_Module, Callback, Arity, #docs_v1{docs = Docs} = D, Config) -> +render_callback(Module, Callback, Arity, #docs_v1{docs = Docs} = D, Config) -> render_typecb_docs( lists:filter( fun @@ -309,6 +314,7 @@ render_callback(_Module, Callback, Arity, #docs_v1{docs = Docs} = D, Config) -> Docs ), D, + Module, Config ). @@ -353,11 +359,11 @@ normalize_format(Docs, #docs_v1{format = <<"text/", _/binary>>}) when is_binary( [{pre, [], [Docs]}]. %%% Functions for rendering reference documentation --spec render_function([chunk_entry()], #docs_v1{}, map()) -> +-spec render_function([chunk_entry()], #docs_v1{}, atom(), map()) -> unicode:chardata() | {'error', 'function_missing'}. -render_function([], _D, _Config) -> +render_function([], _D, _Module, _Config) -> {error, function_missing}; -render_function(FDocs, #docs_v1{docs = Docs} = D, Config) -> +render_function(FDocs, #docs_v1{docs = Docs} = D, Module, Config) -> Grouping = lists:foldl( fun @@ -375,7 +381,7 @@ render_function(FDocs, #docs_v1{docs = Docs} = D, Config) -> fun({Group, Members}) -> lists:map( fun(Member = {_, _, _, Doc, _}) -> - Sig = render_signature(Member), + Sig = render_signature(Member, Module), LocalDoc = if Doc =:= #{} -> @@ -399,8 +405,8 @@ render_function(FDocs, #docs_v1{docs = Docs} = D, Config) -> ). %% Render the signature of either function, type, or anything else really. --spec render_signature(chunk_entry()) -> chunk_elements(). -render_signature({{_Type, _F, _A}, _Anno, _Sigs, _Docs, #{signature := Specs} = Meta}) -> +-spec render_signature(chunk_entry(), module()) -> chunk_elements() | els_poi:poi(). +render_signature({{_Type, _F, _A}, _Anno, _Sigs, _Docs, #{signature := Specs} = Meta}, _Module) -> lists:flatmap( fun(ASTSpec) -> PPSpec = erl_pp:attribute(ASTSpec, [{encoding, utf8}]), @@ -424,8 +430,13 @@ render_signature({{_Type, _F, _A}, _Anno, _Sigs, _Docs, #{signature := Specs} = end, Specs ); -render_signature({{_Type, _F, _A}, _Anno, Sigs, _Docs, Meta}) -> - [{pre, [], Sigs}, {hr, [], []} | render_meta(Meta)]. +render_signature({{_Type, F, A}, _Anno, Sigs, _Docs, Meta}, Module) -> + case els_dt_signatures:lookup({Module, F, A}) of + {ok, [#{spec := <<"-spec ", Spec/binary>>}]} -> + [{pre, [], Spec}, {hr, [], []} | render_meta(Meta)]; + {ok, _} -> + [{pre, [], Sigs}, {hr, [], []} | render_meta(Meta)] + end. -spec trim_spec(unicode:chardata()) -> unicode:chardata(). trim_spec(Spec) -> @@ -499,7 +510,7 @@ render_headers_and_docs(Headers, DocContents, #config{} = Config) -> render_docs(DocContents, 0, Config) ]. --spec render_typecb_docs([TypeCB] | TypeCB, #config{}) -> +-spec render_typecb_docs([TypeCB] | TypeCB, module(), #config{}) -> unicode:chardata() | {'error', 'type_missing'} when TypeCB :: { @@ -508,16 +519,20 @@ when Sig :: [binary()], none | hidden | #{binary() => chunk_elements()} }. -render_typecb_docs([], _C) -> +render_typecb_docs([], _Module, _C) -> {error, type_missing}; -render_typecb_docs(TypeCBs, #config{} = C) when is_list(TypeCBs) -> - [render_typecb_docs(TypeCB, C) || TypeCB <- TypeCBs]; -render_typecb_docs({F, _, _Sig, Docs, _Meta} = TypeCB, #config{docs = D} = C) -> - render_headers_and_docs(render_signature(TypeCB), get_local_doc(F, Docs, D), C). --spec render_typecb_docs(chunk_elements(), #docs_v1{}, _) -> +render_typecb_docs(TypeCBs, Module, #config{} = C) when is_list(TypeCBs) -> + [render_typecb_docs(TypeCB, Module, C) || TypeCB <- TypeCBs]; +render_typecb_docs({F, _, _Sig, Docs, _Meta} = TypeCB, Module, #config{docs = D} = C) -> + render_headers_and_docs( + render_signature(TypeCB, Module), + get_local_doc(F, Docs, D), + C + ). +-spec render_typecb_docs(chunk_elements(), #docs_v1{}, module(), _) -> unicode:chardata() | {'error', 'type_missing'}. -render_typecb_docs(Docs, D, Config) -> - render_typecb_docs(Docs, init_config(D, Config)). +render_typecb_docs(Docs, D, Module, Config) -> + render_typecb_docs(Docs, Module, init_config(D, Config)). %%% General rendering functions -spec render_docs([chunk_element()], #config{}) -> unicode:chardata(). @@ -540,6 +555,12 @@ init_config(D, _Config) -> #config{} ) -> {unicode:chardata(), non_neg_integer()}. +render_docs(Str, State, Pos, Ind, D) when + is_list(Str), + is_integer(hd(Str)) +-> + %% This is a string, convert it to binary. + render_docs([unicode:characters_to_binary(Str)], State, Pos, Ind, D); render_docs(Elems, State, Pos, Ind, D) when is_list(Elems) -> lists:mapfoldl( fun(Elem, P) -> diff --git a/apps/els_lsp/test/els_completion_SUITE.erl b/apps/els_lsp/test/els_completion_SUITE.erl index 0f53eeb04..7c7535a0a 100644 --- a/apps/els_lsp/test/els_completion_SUITE.erl +++ b/apps/els_lsp/test/els_completion_SUITE.erl @@ -1790,18 +1790,25 @@ resolve_application_remote_otp(Config) -> case has_eep48(file) of true when OtpRelease >= 27 -> << - "## file:write/2\n\n" - "---\n\n```erlang\n\n" - " write(File, Bytes) when is_pid(File) orelse is_atom(File)\n\n" - " write(#file_descriptor{module = Module} = Handle, Bytes) \n\n" - " write(_, _) \n\n" - "```\n\n" "```erlang\n" - "-spec write(IoDevice, Bytes) -> ok | {error, Reason} when\n" + "write(IoDevice, Bytes) -> ok | {error, Reason} when\n" " IoDevice :: io_device() | io:device(),\n" " Bytes :: iodata(),\n" " Reason :: posix() | badarg | terminated.\n" - "```" + "```\n\n---\n\n```" + "erlang\nWrites `Bytes` to the file referenced by `IoDevice`." + " This function is the only\nway to write to a file opened in" + " `raw` mode (although it works for normally\nopened files" + " too). Returns `ok` if successful, and `{error, Reason}`" + " otherwise.\n\nIf the file is opened with `encoding` set to" + " something else than `latin1`, each\nbyte written can result" + " in many bytes being written to the file, as the byte\nrange" + " 0..255 can represent anything between one and four bytes" + " depending on\nvalue and UTF encoding type. If you want to" + " write `t:unicode:chardata/0` to the\n`IoDevice` you should" + " use `io:put_chars/2` instead.\n\nTypical error reasons:\n\n" + "- **`ebadf`** - The file is not opened for writing.\n\n" + "- **`enospc`** - No space is left on the device.\n```\n" >>; true when OtpRelease == 26 -> << @@ -2019,10 +2026,17 @@ resolve_type_application_remote_otp(Config) -> case has_eep48(file) of true when OtpRelease >= 27 -> << - "```erlang\n" - "-type name_all() :: string() | atom() | deep_list() |" - " (RawFilename :: binary()).\n" - "```" + "```erlang\nname_all()\n```\n\n---\n\n" + "```erlang\nA file name used as input into `m:file` API" + " functions.\n\nIf VM is in Unicode filename mode," + " characters are allowed to be > 255.\n`RawFilename`" + " is a filename not subject to Unicode translation," + " meaning that it\ncan contain characters not conforming" + " to the Unicode encoding expected from the\nfile system" + " (that is, non-UTF-8 characters although the VM is" + " started in Unicode\nfilename mode). Null characters" + " (integer value zero) are _not_ allowed in\nfilenames" + " (not even at the end).\n```\n" >>; true -> << diff --git a/apps/els_lsp/test/els_hover_SUITE.erl b/apps/els_lsp/test/els_hover_SUITE.erl index 343e79178..9e06a4f96 100644 --- a/apps/els_lsp/test/els_hover_SUITE.erl +++ b/apps/els_lsp/test/els_hover_SUITE.erl @@ -215,17 +215,25 @@ remote_call_otp(Config) -> case has_eep48(file) of true when OtpRelease >= 27 -> << - "## file:write/2\n\n---\n\n" - "```erlang\n\n write(File, Bytes) when is_pid(File) orelse is_atom(File)\n\n" - " write(#file_descriptor{module = Module} = Handle, Bytes) \n\n" - " write(_, _) \n\n" - "```\n\n" - "```erlang\n" - "-spec write(IoDevice, Bytes) -> ok | {error, Reason} when\n" - " IoDevice :: io_device() | io:device(),\n" + "```erlang\nwrite(IoDevice, Bytes) -> ok | {error, Reason}" + " when\n IoDevice :: io_device() | io:device(),\n" " Bytes :: iodata(),\n" " Reason :: posix() | badarg | terminated.\n" - "```" + "```\n\n---\n\n" + "```erlang\nWrites `Bytes` to the file referenced by" + " `IoDevice`. This function is the only\nway to write to a" + " file opened in `raw` mode (although it works for normally\n" + "opened files too). Returns `ok` if successful, and" + " `{error, Reason}` otherwise.\n\nIf the file is opened with" + " `encoding` set to something else than `latin1`, each\nbyte" + " written can result in many bytes being written to the file," + " as the byte\nrange 0..255 can represent anything between" + " one and four bytes depending on\nvalue and UTF encoding" + " type. If you want to write `t:unicode:chardata/0` to the\n" + "`IoDevice` you should use `io:put_chars/2` instead.\n\n" + "Typical error reasons:\n\n" + "- **`ebadf`** - The file is not opened for writing.\n\n" + "- **`enospc`** - No space is left on the device.\n```\n" >>; true when OtpRelease == 26 -> <<