lib/credo/source_file.ex

defmodule Credo.SourceFile do
  @moduledoc """
  `SourceFile` structs represent a source file in the codebase.
  """

  @type t :: %__MODULE__{
          filename: nil | String.t(),
          hash: String.t(),
          status: :valid | :invalid | :timed_out
        }

  alias Credo.Service.SourceFileAST
  alias Credo.Service.SourceFileLines
  alias Credo.Service.SourceFileSource

  defstruct filename: nil,
            hash: nil,
            status: nil

  defimpl Inspect, for: __MODULE__ do
    def inspect(source_file, _opts) do
      "%SourceFile<#{source_file.filename}>"
    end
  end

  @doc """
  Returns a `SourceFile` struct for the given `source` code and `filename`.
  """
  def parse(source, filename) do
    filename = Path.relative_to_cwd(filename)

    lines = Credo.Code.to_lines(source)

    {valid, ast} =
      case Credo.Code.ast(source) do
        {:ok, ast} ->
          {true, ast}

        {:error, _errors} ->
          {false, []}
      end

    hash =
      :sha256
      |> :crypto.hash(source)
      |> Base.encode16()

    source_file = %Credo.SourceFile{
      filename: filename,
      hash: hash,
      status: if(valid, do: :valid, else: :invalid)
    }

    SourceFileAST.put(source_file, ast)
    SourceFileLines.put(source_file, lines)
    SourceFileSource.put(source_file, source)

    source_file
  end

  @spec timed_out(String.t()) :: t
  def timed_out(filename) do
    filename = Path.relative_to_cwd(filename)

    %Credo.SourceFile{
      filename: filename,
      hash: "timed_out:#{filename}",
      status: :timed_out
    }
  end

  @doc "Returns the AST for the given `source_file`."
  def ast(source_file)

  def ast(%__MODULE__{} = source_file) do
    case SourceFileAST.get(source_file) do
      {:ok, ast} ->
        ast

      _ ->
        raise "Could not get source from ETS: #{source_file.filename}"
    end
  end

  @doc "Returns the lines of source code for the given `source_file`."
  def lines(source_file)

  def lines(%__MODULE__{} = source_file) do
    case SourceFileLines.get(source_file) do
      {:ok, lines} ->
        lines

      _ ->
        raise "Could not get source from ETS: #{source_file.filename}"
    end
  end

  @doc "Returns the source code for the given `source_file`."
  def source(source_file)

  def source(%__MODULE__{} = source_file) do
    case SourceFileSource.get(source_file) do
      {:ok, source} ->
        source

      _ ->
        raise "Could not get source from ETS: #{source_file.filename}"
    end
  end

  @doc "Returns the source code and filename for the given `source_file_or_source`."
  def source_and_filename(source_file_or_source, default_filename \\ "nofilename")

  def source_and_filename(%__MODULE__{filename: filename} = source_file, _default_filename) do
    {source(source_file), filename}
  end

  def source_and_filename(source, default_filename) when is_binary(source) do
    {source, default_filename}
  end

  @doc """
  Returns the line at the given `line_no`.

  NOTE: `line_no` is a 1-based index.
  """
  def line_at(%__MODULE__{} = source_file, line_no) do
    source_file
    |> lines()
    |> Enum.find_value(&find_line_at(&1, line_no))
  end

  defp find_line_at({line_no, text}, line_no), do: text
  defp find_line_at(_, _), do: nil

  @doc """
  Returns the snippet at the given `line_no` between `column1` and `column2`.

  NOTE: `line_no` is a 1-based index.
  """
  def line_at(%__MODULE__{} = source_file, line_no, column1, column2) do
    source_file
    |> line_at(line_no)
    |> String.slice(column1 - 1, column2 - column1)
  end

  @doc """
  Returns the column of the given `trigger` inside the given line.

  NOTE: Both `line_no` and the returned index are 1-based.
  """
  def column(source_file, line_no, trigger)

  def column(%__MODULE__{} = source_file, line_no, trigger)
      when is_binary(trigger) or is_atom(trigger) do
    line = line_at(source_file, line_no)

    regexed =
      trigger
      |> to_string
      |> Regex.escape()

    case Regex.run(~r/(\b|\(|\)|\,)(#{regexed})(\b|\(|\)|\,)/, line, return: :index) do
      nil ->
        nil

      [_, _, {regexed_col, _regexed_length}, _] ->
        regexed_col + 1
    end
  end

  def column(_, _, _), do: nil
end