defmodule Livebook.Runtime.Dependencies do @doc """ Finds or adds a `Mix.install/2` call to `code` and modifies it to include the given Mix deps. A config may be given, in which case it is added or merged into the `Mix.install/2` `:config` option. """ @spec insert_mix_dependencies(String.t(), list(Livebook.Runtime.dependency())) :: {:ok, String.t()} | {:error, String.t()} def insert_mix_dependencies(code, dependencies) do deps = Enum.map(dependencies, & &1.dep) config = Enum.reduce(dependencies, [], &Livebook.Utils.keyword_deep_merge(&2, &1.config)) add_mix_deps(code, deps, config) end defp add_mix_deps(code, deps, config) do with {:ok, ast, comments} <- string_to_quoted_with_comments(code), {:ok, ast} <- insert_deps(ast, deps, config), do: {:ok, format(ast, comments)} end defp string_to_quoted_with_comments(code) do try do to_quoted_opts = [ literal_encoder: &{:ok, {:__block__, &2, [&1]}}, token_metadata: true, unescape: false ] {ast, comments} = Code.string_to_quoted_with_comments!(code, to_quoted_opts) {:ok, ast, comments} rescue error -> {:error, Exception.format(:error, error)} end end defp insert_deps(ast, deps, config) do with :error <- update_install(ast, deps, config) do dep_nodes = Enum.map(deps, &dep_node/1) install_args = [{:__block__, [newlines: 1], [dep_nodes]}] ++ case config do [] -> [] config -> [[config: Macro.escape(config)]] end install_node = {{:., [], [{:__aliases__, [], [:Mix]}, :install]}, [newlines: 1], install_args} {:ok, prepend_node(ast, install_node)} end end defp format(ast, comments) do ast |> Code.quoted_to_algebra(comments: comments) |> Inspect.Algebra.format(90) |> IO.iodata_to_binary() end defp prepend_node({:__block__, meta, nodes}, node) do {:__block__, meta, [node | nodes]} end defp prepend_node(ast, node) do {:__block__, [], [node, ast]} end defp update_install( {{:., _, [{:__aliases__, _, [:Mix]}, :install]} = target, meta1, [{:__block__, meta2, [dep_nodes]} | args]}, deps, config ) do args = case {args, config} do {args, []} -> args {[], config} -> [[config: Macro.escape(config)]] {[opts | other_args], config} -> opts = map_maybe_block(opts, &update_config_in_opts(&1, config)) [opts | other_args] end meta1 = Keyword.put(meta1, :newlines, 1) new_dep_nodes = for dep <- deps, not has_dep?(dep_nodes, dep), do: dep_node(dep) {:ok, {target, meta1, [{:__block__, meta2, [dep_nodes ++ new_dep_nodes]} | args]}} end defp update_install({:__block__, meta, nodes}, deps, config) do {nodes, found} = Enum.map_reduce(nodes, _found = false, fn node, false -> case update_install(node, deps, config) do {:ok, node} -> {node, true} _ -> {node, false} end node, true -> {node, true} end) if found do {:ok, {:__block__, meta, nodes}} else :error end end defp update_install(_node, _deps, _config), do: :error defp has_dep?(deps, dep) do name = elem(dep, 0) Enum.any?(deps, fn {:__block__, _, [{{:__block__, _, [^name]}, _}]} -> true {:{}, _, [{:__block__, _, [^name]} | _]} -> true _ -> false end) end defp dep_node(dep), do: {:__block__, [], [Macro.escape(dep)]} defp update_config_in_opts( [{{:__block__, meta1, [:config]}, {:__block__, meta2, [current_config]}} | tail], config ) do config = append_config(current_config, config) [{{:__block__, meta1, [:config]}, {:__block__, meta2, [config]}} | tail] end defp update_config_in_opts([head | tail], config), do: [head | update_config_in_opts(tail, config)] defp update_config_in_opts([], config), do: [config: Macro.escape(config)] defp append_config(current_config, config) do # Note: the current config has literals wrapped in a :__block__, # while the new one doesn't. # If the given key is already in the config, we ignore it existing_keys = for {{:__block__, _meta, [key]}, _value} <- current_config, do: key config = Keyword.drop(config, existing_keys) # We need to wrap literals in a block, as in the rest of the AST to_quoted_opts = [literal_encoder: &{:ok, {:__block__, &2, [&1]}}] {:__block__, _, [items]} = config |> Macro.to_string() |> Code.string_to_quoted!(to_quoted_opts) current_config ++ items end defp map_maybe_block({:__block__, meta, [value]}, fun), do: {:__block__, meta, [fun.(value)]} defp map_maybe_block(value, fun), do: fun.(value) @doc """ Parses a plain Elixir term from its string representation. ## Examples iex> Livebook.Runtime.Dependencies.parse_term(~s|{:req, "~> 0.5.0"}|) {:ok, {:req, "~> 0.5.0"}} iex> Livebook.Runtime.Dependencies.parse_term(~s|{:req, "~> 0.5.0", runtime: false, meta: "data"}|) {:ok, {:req, "~> 0.5.0", runtime: false, meta: "data"}} iex> Livebook.Runtime.Dependencies.parse_term(~s|%{name: "Jake", numbers: [1, 2, 3.4]}|) {:ok, %{name: "Jake", numbers: [1, 2, 3.4]}} iex> Livebook.Runtime.Dependencies.parse_term(~s|{:call, Enum.count([])}|) :error iex> Livebook.Runtime.Dependencies.parse_term(~s|Enum|) :error """ @spec parse_term(String.t()) :: {:ok, term()} | :error def parse_term(string) do case Code.string_to_quoted(string) do {:ok, ast} -> unescape_term(ast) {:error, _} -> :error end end defp unescape_term(node) when is_atom(node) when is_integer(node) when is_float(node) when is_binary(node), do: {:ok, node} defp unescape_term([]), do: {:ok, []} defp unescape_term([head | tail]) do with {:ok, head} <- unescape_term(head), {:ok, tail} <- unescape_term(tail), do: {:ok, [head | tail]} end defp unescape_term({left, right}) do with {:ok, left} <- unescape_term(left), {:ok, right} <- unescape_term(right), do: {:ok, {left, right}} end defp unescape_term({:{}, _, nodes}) do with {:ok, terms} <- unescape_term(nodes), do: {:ok, List.to_tuple(terms)} end defp unescape_term({:%{}, _, nodes}) do with {:ok, terms} <- unescape_term(nodes), do: {:ok, Map.new(terms)} end defp unescape_term(_node), do: :error @doc """ Searches packages within the given list. """ @spec search_packages_in_list(list(Livebook.Runtime.package_details()), String.t()) :: {:ok, list(Livebook.Runtime.package_details())} def search_packages_in_list(packages, search) do matching_packages = Enum.filter(packages, &String.starts_with?(&1.name, search)) {:ok, matching_packages} end @doc """ Searches for packages on Hex. ## Options * `:api_url` - the base URL for Hex API requests. Optional """ @spec search_packages_on_hex(String.t(), keyword()) :: {:ok, list(Livebook.Runtime.package_details())} | {:error, String.t()} def search_packages_on_hex(search, opts \\ []) def search_packages_on_hex("", _opts), do: {:ok, []} def search_packages_on_hex(search, opts) do api_url = opts[:api_url] || "https://hex.pm/api" req = Req.new(base_url: api_url) |> Livebook.Utils.req_attach_defaults() params = [search: "name:#{search}*", sort: "recent_downloads"] case Req.get(req, url: "/packages", params: params) do {:ok, %{status: 200} = resp} -> packages = resp.body |> Enum.map(&parse_package/1) |> reorder_packages(search) {:ok, packages} {:ok, %{status: status}} -> {:error, "unexpected response, HTTP status #{status}"} {:error, exception} -> {:error, "failed to make a request, reason: #{Exception.message(exception)}}"} end end defp parse_package(package) do {:ok, dep} = parse_term(package["configs"]["mix.exs"]) %{ name: package["name"], version: package["latest_stable_version"] || package["latest_version"], description: package["meta"]["description"], url: package["html_url"], dependency: %{dep: dep, config: []} } end defp reorder_packages(packages, search) do case Enum.find_index(packages, &(&1.name == search)) do nil -> packages exact_idx -> {package, packages} = List.pop_at(packages, exact_idx) [package | packages] end end end