Skip to content

Commit

Permalink
harden docs fetching against changing cwd
Browse files Browse the repository at this point in the history
  • Loading branch information
lukaszsamson committed Nov 7, 2023
1 parent b2e26ce commit 012f415
Show file tree
Hide file tree
Showing 3 changed files with 300 additions and 4 deletions.
124 changes: 123 additions & 1 deletion lib/elixir_sense/core/normalized/code.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule ElixirSense.Core.Normalized.Code do

alias ElixirSense.Core.Behaviours
alias ElixirSense.Core.ErlangHtml
alias ElixirSense.Core.Normalized.Path, as: PathNormalized

@type doc_t :: nil | false | String.t()
@type fun_doc_entry_t ::
Expand All @@ -22,7 +23,7 @@ defmodule ElixirSense.Core.Normalized.Code do
@spec get_docs(module, :callback_docs | :type_docs) :: nil | [:doc_entry_t]
@spec get_docs(module, :moduledoc) :: nil | moduledoc_entry_t
def get_docs(module, category) do
case Code.fetch_docs(module) do
case fetch_docs(module) do
{:docs_v1, moduledoc_anno, _language, mime_type, moduledoc, metadata, docs}
when mime_type in @supported_mime_types ->
case category do
Expand Down Expand Up @@ -186,4 +187,125 @@ defmodule ElixirSense.Core.Normalized.Code do
end

defp maybe_mark_as_hidden(metadata, _text), do: metadata

# the functions below are copied from elixir project
# https://github.com/lukaszsamson/elixir/blob/bf3e2fd3ad78235bda059b80994a90d9a4184353/lib/elixir/lib/code.ex
# with applied https://github.com/elixir-lang/elixir/pull/13061
# and https://github.com/elixir-lang/elixir/pull/13075
# as well as some small stability fixes
# TODO remove when we require elixir 1.16
# The original code is licensed as follows:
#
# Copyright 2012 Plataformatec
# Copyright 2021 The Elixir Team
#
# 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
#
# https://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.

@spec fetch_docs(module | String.t()) ::
{:docs_v1, annotation, beam_language, format, module_doc :: doc_content, metadata,
docs :: [doc_element]}
| {:error, any}
when annotation: :erl_anno.anno(),
beam_language: :elixir | :erlang | atom(),
doc_content: %{optional(binary) => binary} | :none | :hidden,
doc_element:
{{kind :: atom, function_name :: atom, arity}, annotation, signature, doc_content,
metadata},
format: binary,
signature: [binary],
metadata: map
def fetch_docs(module_or_path)

def fetch_docs(module) when is_atom(module) do
case get_beam_and_path(module) do
{bin, beam_path} ->
case fetch_docs_from_beam(bin) do
{:error, :chunk_not_found} ->
app_root = PathNormalized.expand(Path.join(["..", ".."]), beam_path)
path = Path.join([app_root, "doc", "chunks", "#{module}.chunk"])
fetch_docs_from_chunk(path)

other ->
other
end

:error ->
case :code.is_loaded(module) do
{:file, :preloaded} ->
# The ERTS directory is not necessarily included in releases
# unless it is listed as an extra application.
case :code.lib_dir(:erts) do
path when is_list(path) ->
path = Path.join([path, "doc", "chunks", "#{module}.chunk"])
fetch_docs_from_chunk(path)

{:error, _} ->
{:error, :chunk_not_found}
end

_ ->
{:error, :module_not_found}
end
end
end

def fetch_docs(path) when is_binary(path) do
fetch_docs_from_beam(String.to_charlist(path))
end

defp get_beam_and_path(module) do
with {^module, beam, filename} <- :code.get_object_code(module),
info_pairs when is_list(info_pairs) <- :beam_lib.info(beam),
{:ok, ^module} <- Keyword.fetch(info_pairs, :module) do
{beam, filename}
else
_ -> :error
end
end

@docs_chunk [?D, ?o, ?c, ?s]

defp fetch_docs_from_beam(bin_or_path) do
case :beam_lib.chunks(bin_or_path, [@docs_chunk]) do
{:ok, {_module, [{@docs_chunk, bin}]}} ->
load_docs_chunk(bin)

{:error, :beam_lib, {:missing_chunk, _, @docs_chunk}} ->
{:error, :chunk_not_found}

{:error, :beam_lib, {:file_error, _, :enoent}} ->
{:error, :module_not_found}

# TODO raise a PR to elixir
{:error, :beam_lib, reason} ->
{:error, reason}
end
end

defp fetch_docs_from_chunk(path) do
case File.read(path) do
{:ok, bin} ->
load_docs_chunk(bin)

{:error, _} ->
{:error, :chunk_not_found}
end
end

defp load_docs_chunk(bin) do
:erlang.binary_to_term(bin)
rescue
_ ->
{:error, {:invalid_chunk, bin}}
end
end
173 changes: 173 additions & 0 deletions lib/elixir_sense/core/normalized/path.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
defmodule ElixirSense.Core.Normalized.Path do

Check warning on line 1 in lib/elixir_sense/core/normalized/path.ex

View workflow job for this annotation

GitHub Actions / static analysis (Elixir 1.15.x | Erlang/OTP 26.x)

Modules should have a @moduledoc tag.
# the functions below are copied from elixir project
# https://github.com/lukaszsamson/elixir/blob/bf3e2fd3ad78235bda059b80994a90d9a4184353/lib/elixir/lib/path.ex
# with applied https://github.com/elixir-lang/elixir/pull/13061
# TODO remove when we require elixir 1.16
# The original code is licensed as follows:
#
# Copyright 2012 Plataformatec
# Copyright 2021 The Elixir Team
#
# 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
#
# https://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.

@type t :: IO.chardata()

@spec absname(t) :: binary
def absname(path) do
absname(path, &File.cwd!/0)
end

@spec absname(t, t) :: binary
def absname(path, relative_to) do
path = IO.chardata_to_string(path)

case Path.type(path) do
:relative ->
relative_to =
if is_function(relative_to, 0) do
relative_to.()
else
relative_to
end

absname_join([relative_to, path])

:absolute ->
absname_join([path])

:volumerelative ->
relative_to =
if is_function(relative_to, 0) do
relative_to.()
else
relative_to
end
|> IO.chardata_to_string()

absname_vr(Path.split(path), Path.split(relative_to), relative_to)
end
end

# Absolute path on current drive
defp absname_vr(["/" | rest], [volume | _], _relative), do: absname_join([volume | rest])

# Relative to current directory on current drive
defp absname_vr([<<x, ?:>> | rest], [<<x, _::binary>> | _], relative),
do: absname(absname_join(rest), relative)

# Relative to current directory on another drive
defp absname_vr([<<x, ?:>> | name], _, _relative) do
cwd =
case :file.get_cwd([x, ?:]) do
{:ok, dir} -> IO.chardata_to_string(dir)
{:error, _} -> <<x, ?:, ?/>>
end

absname(absname_join(name), cwd)
end

@slash [?/, ?\\]

defp absname_join([]), do: ""
defp absname_join(list), do: absname_join(list, major_os_type())

defp absname_join([name1, name2 | rest], os_type) do
joined = do_absname_join(IO.chardata_to_string(name1), Path.relative(name2), [], os_type)
absname_join([joined | rest], os_type)
end

defp absname_join([name], os_type) do
do_absname_join(IO.chardata_to_string(name), <<>>, [], os_type)
end

defp do_absname_join(<<uc_letter, ?:, rest::binary>>, relativename, [], :win32)
when uc_letter in ?A..?Z,
do: do_absname_join(rest, relativename, [?:, uc_letter + ?a - ?A], :win32)

defp do_absname_join(<<c1, c2, rest::binary>>, relativename, [], :win32)
when c1 in @slash and c2 in @slash,
do: do_absname_join(rest, relativename, ~c"//", :win32)

defp do_absname_join(<<?\\, rest::binary>>, relativename, result, :win32),
do: do_absname_join(<<?/, rest::binary>>, relativename, result, :win32)

defp do_absname_join(<<?/, rest::binary>>, relativename, [?., ?/ | result], os_type),
do: do_absname_join(rest, relativename, [?/ | result], os_type)

defp do_absname_join(<<?/, rest::binary>>, relativename, [?/ | result], os_type),
do: do_absname_join(rest, relativename, [?/ | result], os_type)

defp do_absname_join(<<>>, <<>>, result, os_type),
do: IO.iodata_to_binary(reverse_maybe_remove_dir_sep(result, os_type))

defp do_absname_join(<<>>, relativename, [?: | rest], :win32),
do: do_absname_join(relativename, <<>>, [?: | rest], :win32)

defp do_absname_join(<<>>, relativename, [?/ | result], os_type),
do: do_absname_join(relativename, <<>>, [?/ | result], os_type)

defp do_absname_join(<<>>, relativename, result, os_type),
do: do_absname_join(relativename, <<>>, [?/ | result], os_type)

defp do_absname_join(<<char, rest::binary>>, relativename, result, os_type),
do: do_absname_join(rest, relativename, [char | result], os_type)

defp reverse_maybe_remove_dir_sep([?/, ?:, letter], :win32), do: [letter, ?:, ?/]
defp reverse_maybe_remove_dir_sep([?/], _), do: [?/]
defp reverse_maybe_remove_dir_sep([?/ | name], _), do: :lists.reverse(name)
defp reverse_maybe_remove_dir_sep(name, _), do: :lists.reverse(name)

@spec expand(t) :: binary
def expand(path) do
expand_dot(absname(expand_home(path), &File.cwd!/0))
end

@spec expand(t, t) :: binary
def expand(path, relative_to) do
expand_dot(absname(absname(expand_home(path), expand_home(relative_to)), &File.cwd!/0))
end

defp expand_home(type) do
case IO.chardata_to_string(type) do
"~" <> rest -> resolve_home(rest)
rest -> rest
end
end

defp resolve_home(""), do: System.user_home!()

defp resolve_home(rest) do
case {rest, major_os_type()} do
{"\\" <> _, :win32} -> System.user_home!() <> rest
{"/" <> _, _} -> System.user_home!() <> rest
_ -> "~" <> rest
end
end

# expands dots in an absolute path represented as a string
defp expand_dot(path) do
[head | tail] = :binary.split(path, "/", [:global])
IO.iodata_to_binary(expand_dot(tail, [head <> "/"]))
end

defp expand_dot([".." | t], [_, _ | acc]), do: expand_dot(t, acc)
defp expand_dot([".." | t], acc), do: expand_dot(t, acc)
defp expand_dot(["." | t], acc), do: expand_dot(t, acc)
defp expand_dot([h | t], acc), do: expand_dot(t, ["/", h | acc])
defp expand_dot([], ["/", head | acc]), do: :lists.reverse([head | acc])
defp expand_dot([], acc), do: :lists.reverse(acc)

defp major_os_type do
:os.type() |> elem(0)
end
end
7 changes: 4 additions & 3 deletions lib/elixir_sense/location.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defmodule ElixirSense.Location do
alias ElixirSense.Core.Parser
alias ElixirSense.Core.Source
alias ElixirSense.Core.State.ModFunInfo
alias ElixirSense.Core.Normalized.Code, as: CodeNormalized
require ElixirSense.Core.Introspection, as: Introspection
alias ElixirSense.Location

Expand Down Expand Up @@ -192,7 +193,7 @@ defmodule ElixirSense.Location do
end

defp get_function_position_using_docs(module, nil, _) do
case Code.fetch_docs(module) do
case CodeNormalized.fetch_docs(module) do
{:error, _} ->
nil

Expand Down Expand Up @@ -220,7 +221,7 @@ defmodule ElixirSense.Location do
end

defp get_function_position_using_docs(module, function, arity) do
case Code.fetch_docs(module) do
case CodeNormalized.fetch_docs(module) do
{:error, _} ->
nil

Expand Down Expand Up @@ -257,7 +258,7 @@ defmodule ElixirSense.Location do
end

def get_type_position_using_docs(module, type_name, arity) do
case Code.fetch_docs(module) do
case CodeNormalized.fetch_docs(module) do
{:error, _} ->
nil

Expand Down

0 comments on commit 012f415

Please sign in to comment.