Skip to main content
Glama
mix_deps.ex8.55 kB
defmodule HexdocsMcp.MixDeps do @moduledoc """ Functionality for reading and processing Mix dependencies using AST evaluation. """ @behaviour HexdocsMcp.Behaviours.MixDeps @doc """ Reads dependencies from a mix.exs file and returns a list of hex packages. Only returns dependencies that are from hex.pm. Evaluates the project/0 function in a sandboxed environment. """ def read_deps(mix_file_path) do if !File.exists?(mix_file_path) do raise "Mix file not found: #{mix_file_path}" end content = File.read!(mix_file_path) case Code.string_to_quoted(content, file: mix_file_path) do {:ok, ast} -> case extract_deps_from_ast(ast, mix_file_path) do {:ok, deps} -> deps |> Enum.filter(&hex_dep?/1) |> Enum.map(&extract_package_info/1) |> Enum.reject(&is_nil(elem(&1, 1))) {:error, reason} -> raise "Failed to extract dependencies: #{reason}" end {:error, {line, description, token}} -> if is_integer(line) and is_binary(description) do raise RuntimeError, "Failed to parse mix.exs at line #{line}: #{description}" else raise RuntimeError, "Failed to parse mix.exs: Unexpected error format #{inspect({line, description, token})}" end end end defp extract_deps_from_ast({:defmodule, _, [_module_name, [do: module_body]]}, file_path) do module_name = String.to_atom("MixDepsEval#{System.unique_integer([:positive])}") try do project_func_ast = find_project_function_ast(module_body) project_body = extract_project_body(project_func_ast) module_name |> eval_project_body(module_body, project_body, project_func_ast, file_path) |> process_project_config(module_body, file_path) catch {:error, :no_project_function} -> {:error, "No project/0 function found"} end end defp extract_deps_from_ast(_, _file_path), do: {:error, "Invalid module structure"} defp process_project_config({:ok, project_config}, module_body, file_path) when is_list(project_config) do case Keyword.get(project_config, :deps) do nil -> {:ok, []} [] -> {:ok, []} deps when is_list(deps) -> {:ok, deps} {:deps, _, _} -> deps_func = find_deps_function_ast(module_body) eval_deps_function(deps_func, file_path) invalid_deps -> {:error, "Invalid format for :deps value: #{inspect(invalid_deps)}"} end end defp process_project_config({:error, reason}, _module_body, _file_path) do {:error, "Failed to evaluate project configuration: #{reason}"} end defp process_project_config(other, _module_body, _file_path) do {:error, "Project function evaluation returned non-list: #{inspect(other)}"} end defp find_project_function_ast({:__block__, _, stats}) when is_list(stats) do Enum.find(stats, fn {:def, _, [{:project, _, args}, _]} when is_list(args) and args == [] -> true {:def, _, [{:project, _, args}, _]} when is_nil(args) -> true _ -> false end) || throw({:error, :no_project_function}) end defp find_project_function_ast({:def, _, [{:project, _, args}, _]} = func) when (is_list(args) and args == []) or is_nil(args) do func end defp find_project_function_ast({:use, _, [{:__aliases__, _, [:Mix, :Project]} | _]}) do {:def, [], [{:project, [], []}, [do: {:__block__, [], []}]]} end defp find_project_function_ast(_) do raise "Failed to extract dependencies: No project/0 function found" end defp find_deps_function_ast({:__block__, _, stats}) when is_list(stats) do Enum.find(stats, fn {def_type, _, [{:deps, _, args}, _]} when def_type in [:def, :defp] and ((is_list(args) and args == []) or is_nil(args)) -> true _ -> false end) end defp find_deps_function_ast(_), do: nil defp extract_project_body({:def, _, [{:project, _, _}, [do: project_body]]}) do project_body end defp extract_project_body({:def, _, [{:project, _, _}, body]}) do case Keyword.get(body, :do) do nil -> {:__block__, [], []} do_block -> do_block end end defp eval_project_body(_module_name, _module_body, project_body, _project_func_ast, _file_path) do result = try_direct_evaluation(project_body) case result do {:ok, _value} -> result {:error, _reason} -> try do bindings = [ {:version, "0.1.0"}, {:app_version, "0.1.0"}, {:elixir_version, "~> 1.14.0"}, {:@, [{:version, [line: 1], nil}, "1.0.0"]} ] case Code.eval_quoted(project_body, bindings) do {result, _binding} when is_list(result) -> {:ok, result} {result, _binding} -> {:error, "Project function returned non-list: #{inspect(result)}"} end rescue e in [CompileError, ArithmeticError, ArgumentError] -> {:error, Exception.message(e)} e -> {:error, "Evaluation error: #{inspect(e)}"} end end end defp try_direct_evaluation(body) do case body do list when is_list(list) -> if Keyword.keyword?(list) do deps = Keyword.get(list, :deps, []) processed_deps = process_deps_ast(deps) {:ok, Keyword.put(list, :deps, processed_deps)} else {:error, "Not a keyword list"} end [_ | _] = list -> try do {:ok, list} rescue _ -> {:error, "Not a literal list"} end _ -> {:error, "Not directly evaluable"} end end defp process_deps_ast(deps) when is_list(deps) do Enum.map(deps, fn {:{}, _, [name, version, _opts]} when is_binary(version) -> {name, version} dep -> dep end) end defp process_deps_ast(deps), do: deps defp eval_deps_function(nil, _file_path), do: {:error, "Could not find deps/0 function"} defp eval_deps_function(deps_func_ast, file_path) do case deps_func_ast do {_def_type, _, [{:deps, _, _}, [do: deps_body]]} -> try do case Code.eval_quoted(deps_body) do {deps, _binding} when is_list(deps) -> {:ok, deps} {result, _binding} -> {:error, "deps function returned non-list: #{inspect(result)}"} end rescue e -> fallback_deps = extract_fallback_deps(file_path) if Enum.empty?(fallback_deps) do {:error, "Failed to evaluate deps function: #{Exception.message(e)}"} else {:ok, fallback_deps} end end _ -> {:error, "Invalid deps function format"} end end defp extract_fallback_deps(file_path) do content = File.read!(file_path) ~r/\{:([a-zA-Z0-9_]+),\s*["']([^"']+)["']/ |> Regex.scan(content) |> Enum.map(fn [_, package, version] -> {to_string(package), normalize_version(version)} end) end defp normalize_version(version) do cond do String.starts_with?(version, ">=") -> version |> String.replace(">=", "") |> String.trim() String.starts_with?(version, ">") -> version |> String.replace(">", "") |> String.trim() String.starts_with?(version, "~>") -> String.trim(version) true -> version end end defp hex_dep?({_package, version}) when is_binary(version), do: true defp hex_dep?({_package, opts}) when is_list(opts) do has_version = Keyword.get(opts, :version) != nil has_hex = Keyword.get(opts, :hex) != nil no_scm = not (Keyword.has_key?(opts, :path) or Keyword.has_key?(opts, :git) or Keyword.has_key?(opts, :github)) (has_version or has_hex) and no_scm end defp hex_dep?({_package, version, opts}) when is_binary(version) and is_list(opts) do not (Keyword.has_key?(opts, :path) or Keyword.has_key?(opts, :git) or Keyword.has_key?(opts, :github)) end defp hex_dep?(_), do: false defp extract_package_info({package, version}) when is_binary(version) do {to_string(package), version} end defp extract_package_info({package, opts}) when is_list(opts) do version = Keyword.get(opts, :version) || Keyword.get(opts, :hex) {to_string(package), version} end defp extract_package_info({package, version, _opts}) when is_binary(version) do {to_string(package), version} end end

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/bradleygolden/hexdocs-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server