lib/lexical/project.ex

defmodule Lexical.Project do
  @moduledoc """
  The representation of the current state of an elixir project.

  This struct contains all the information required to build a project and interrogate its configuration,
  as well as business logic for how to change its attributes.
  """
  alias Lexical.Document

  defstruct root_uri: nil,
            mix_exs_uri: nil,
            mix_project?: false,
            mix_env: nil,
            mix_target: nil,
            env_variables: %{},
            project_module: nil,
            entropy: 1,
            project_config: []

  @type message :: String.t()
  @type restart_notification :: {:restart, Logger.level(), String.t()}
  @type t :: %__MODULE__{
          root_uri: Lexical.uri() | nil,
          mix_exs_uri: Lexical.uri() | nil,
          entropy: non_neg_integer()
          # mix_env: atom(),
          # mix_target: atom(),
          # env_variables: %{String.t() => String.t()}
        }
  @type error_with_message :: {:error, message}

  @workspace_directory_name ".lexical"

  # Public
  @spec new(Lexical.uri()) :: t
  def new(root_uri) do
    entropy = :rand.uniform(65_536)

    %__MODULE__{entropy: entropy}
    |> maybe_set_root_uri(root_uri)
    |> maybe_set_mix_exs_uri()
  end

  @spec set_project_module(t(), module() | nil) :: t()
  def set_project_module(%__MODULE__{} = project, nil) do
    project
  end

  def set_project_module(%__MODULE__{} = project, module) when is_atom(module) do
    %__MODULE__{project | project_module: module, project_config: module.project()}
  end

  @doc """
  Retrieves the name of the project
  """
  @spec name(t) :: String.t()

  def name(%__MODULE__{} = project) do
    sanitized =
      project
      |> folder_name()
      |> String.replace(~r/[^a-zA-Z0-9_]/, "_")

    # This might be a litte verbose, but this code is hot.
    case sanitized do
      <<c::utf8, _rest::binary>> when c in ?a..?z ->
        sanitized

      <<c::utf8, rest::binary>> when c in ?A..?Z ->
        String.downcase("#{[c]}") <> rest

      other ->
        "p_#{other}"
    end
  end

  @doc """
  The project node's name
  """
  def node_name(%__MODULE__{} = project) do
    :"project-#{name(project)}-#{entropy(project)}@127.0.0.1"
  end

  def entropy(%__MODULE__{} = project) do
    project.entropy
  end

  @doc """
  Returns the the name definied in the `project/0` of mix.exs file
  """
  def display_name(%__MODULE__{project_config: []} = project) do
    folder_name(project)
  end

  def display_name(%__MODULE__{} = project) do
    Keyword.get(project.project_config, :name, folder_name(project))
  end

  @doc """
  Retrieves the name of the project as an atom
  """
  @spec atom_name(t) :: atom
  def atom_name(%__MODULE__{project_module: nil} = project) do
    project
    |> name()
    |> String.to_atom()
  end

  def atom_name(%__MODULE__{} = project) do
    project.project_module
  end

  @doc """
  Returns the full path of the project's root directory
  """
  @spec root_path(t) :: Path.t() | nil
  def root_path(%__MODULE__{root_uri: nil}) do
    nil
  end

  def root_path(%__MODULE__{} = project) do
    Document.Path.from_uri(project.root_uri)
  end

  @spec project_path(t) :: Path.t() | nil
  def project_path(%__MODULE__{root_uri: nil}) do
    nil
  end

  def project_path(%__MODULE__{} = project) do
    Document.Path.from_uri(project.root_uri)
  end

  @doc """
  Returns the full path to the project's mix.exs file
  """
  @spec mix_exs_path(t) :: Path.t() | nil
  def mix_exs_path(%__MODULE__{mix_exs_uri: nil}) do
    nil
  end

  def mix_exs_path(%__MODULE__{mix_exs_uri: mix_exs_uri}) do
    Document.Path.from_uri(mix_exs_uri)
  end

  @spec change_environment_variables(t, map() | nil) ::
          {:ok, t} | error_with_message() | restart_notification()
  def change_environment_variables(%__MODULE__{} = project, environment_variables) do
    set_env_vars(project, environment_variables)
  end

  @doc """
  Returns the full path to the project's lexical workspace directory

  Lexical maintains a workspace directory in project it konws about, and places various
  artifacts there. This function returns the full path to that directory
  """
  @spec workspace_path(t) :: String.t()
  def workspace_path(%__MODULE__{} = project) do
    project
    |> root_path()
    |> Path.join(@workspace_directory_name)
  end

  @doc """
  Returns the full path to a file in lexical's workspace directory
  """
  @spec workspace_path(t, String.t() | [String.t()]) :: String.t()
  def workspace_path(%__MODULE__{} = project, relative_path) when is_binary(relative_path) do
    workspace_path(project, [relative_path])
  end

  def workspace_path(%__MODULE__{} = project, relative_path) when is_list(relative_path) do
    Path.join([workspace_path(project) | relative_path])
  end

  @doc """
  Returns the full path to the directory where lexical puts build artifacts
  """
  def build_path(%__MODULE__{} = project) do
    project
    |> workspace_path()
    |> Path.join("build")
  end

  @doc """
  Creates and initializes lexical's workspace directory if it doesn't already exist
  """
  def ensure_workspace(%__MODULE__{} = project) do
    with :ok <- ensure_workspace_directory(project) do
      ensure_git_ignore(project)
    end
  end

  defp ensure_workspace_directory(project) do
    workspace_path = workspace_path(project)

    cond do
      File.exists?(workspace_path) and File.dir?(workspace_path) ->
        :ok

      File.exists?(workspace_path) ->
        :ok = File.rm(workspace_path)
        :ok = File.mkdir_p(workspace_path)

      true ->
        :ok = File.mkdir(workspace_path)
    end
  end

  defp ensure_git_ignore(project) do
    contents = """
    *
    """

    path = workspace_path(project, ".gitignore")

    if File.exists?(path) do
      :ok
    else
      File.write(path, contents)
    end
  end

  defp maybe_set_root_uri(%__MODULE__{} = project, nil),
    do: %__MODULE__{project | root_uri: nil}

  defp maybe_set_root_uri(%__MODULE__{} = project, "file://" <> _ = root_uri) do
    root_path =
      root_uri
      |> Document.Path.absolute_from_uri()
      |> Path.expand()

    if File.exists?(root_path) do
      expanded_uri = Document.Path.to_uri(root_path)
      %__MODULE__{project | root_uri: expanded_uri}
    else
      project
    end
  end

  defp maybe_set_mix_exs_uri(%__MODULE__{} = project) do
    possible_mix_exs_path =
      project
      |> root_path()
      |> find_mix_exs_path()

    if mix_exs_exists?(possible_mix_exs_path) do
      %__MODULE__{
        project
        | mix_exs_uri: Document.Path.to_uri(possible_mix_exs_path),
          mix_project?: true
      }
    else
      project
    end
  end

  # Project Path

  # Environment variables

  def set_env_vars(%__MODULE__{} = old_project, %{} = env_vars) do
    case {old_project.env_variables, env_vars} do
      {nil, vars} when map_size(vars) == 0 ->
        {:ok, %__MODULE__{old_project | env_variables: vars}}

      {nil, new_vars} ->
        System.put_env(new_vars)
        {:ok, %__MODULE__{old_project | env_variables: new_vars}}

      {same, same} ->
        {:ok, old_project}

      _ ->
        {:restart, :warning, "Environment variables have changed. Lexical needs to restart"}
    end
  end

  def set_env_vars(%__MODULE__{} = old_project, _) do
    {:ok, old_project}
  end

  defp find_mix_exs_path(nil) do
    System.get_env("MIX_EXS")
  end

  defp find_mix_exs_path(project_directory) do
    case System.get_env("MIX_EXS") do
      nil ->
        Path.join(project_directory, "mix.exs")

      mix_exs ->
        mix_exs
    end
  end

  defp mix_exs_exists?(nil), do: false

  defp mix_exs_exists?(mix_exs_path) do
    File.exists?(mix_exs_path)
  end

  defp folder_name(project) do
    project
    |> root_path()
    |> Path.basename()
  end
end