lib/logical_file.ex

defmodule LogicalFile do
  alias LogicalFile.{Macro, Section}

  @moduledoc """
  ## LogicalFile

  ### One file from many

  LogicalFile is a way of creating a logical representation of a unit of lines
  of text (e.g. a source code file) supplied by one or more backing files,
  presumably separate files on disk. It also provides for a system of macros
  that can transform the logical text.

  A typical use case for LogicalFile would be to implement a compiler that has
  `#include` style functionality. The compiler works on the whole text but
  can translate logical line numbers back to specific files and local line
  numbers (for example when an error occurs it can pinpoint the specific file
  and line the error arose in).
  """

  defstruct base_path: nil,
            sections: %{}

  @type t :: %__MODULE__{
          base_path: nil | binary,
          sections: map
        }

  # --- Interface ---

  @doc """
  `read/3` returns a new `LogicalFile` containing `Section`s that
  represent the contents of the file specified by `source_path` relative to
  `base_path` and as modified by the macros it is initialised with.

  Macros should implement the `LogicalFile.Macro` behaviours and should be
  specified as a list of tuples of the form `{module, [options keyword list]}`.
  See `LogicalFile.Macro` for further details.

  ## Examples
      iex> file = LogicalFile.read("test/support", "main.source")
      iex> assert 11 = LogicalFile.size(file)
  """
  def read(base_path, source_path, macros \\ [])
      when is_binary(source_path) and is_list(macros) do
    with section = Section.new(Path.join(base_path, source_path)) do
      %LogicalFile{base_path: base_path, sections: %{section.range => section}}
      |> Macro.apply_macros(macros)
    end
  end

  @doc """
  `assemble/2` returns a `LogicalFile` composed of the `Section`s specified in
  the second argument. This is mainly intended for internal use when modifying
  a `LogicalFile` during macro processing.
  """
  def assemble(base_path, sections) when is_list(sections) do
    %LogicalFile{
      base_path: base_path,
      sections: sections |> Enum.map(fn section -> {section.range, section} end) |> Enum.into(%{})
    }
  end

  @doc """
  `line/2` returns the specified logical line number from the `LogicalFile`
  at `lno`.

  ## Example
      iex> file = LogicalFile.read("test/support", "main.source")
      iex> assert "%(include.source)" = LogicalFile.line(file, 6)
  """
  def line(%LogicalFile{} = file, lno) do
    file
    |> section_including_line(lno)
    |> Section.line(lno)
  end

  @doc """
  `insert/3` inserts a new `Section` into the `LogicalFile` at the specified
  logical line number `at_line` and containing the contents of the `source_path`.

  It guarantees that all sections and the logical file remains contiguous.

  ## Examples

  """
  def insert(%LogicalFile{base_path: base_path} = file, source_path, at_line)
      when is_binary(source_path) do
    insert(file, Section.new(Path.join(base_path, source_path)), at_line)
  end

  def insert(
        %LogicalFile{base_path: base_path, sections: sections},
        %Section{} = insert_section,
        at_line
      ) do
    with sections = Map.values(sections),
         {before, target, rest} = partition_sections(sections, at_line) do
      if is_nil(target),
        do: raise("Unable to partition: line:#{at_line} is not in any source section.")

      {pre, post} = Section.split(target, at_line)
      before = before ++ [pre]
      rest = [post | rest]

      insert_section = Section.shift(insert_section, Section.size(pre))

      rest =
        Enum.map(rest, fn section -> Section.shift(section, Section.size(insert_section)) end)

      LogicalFile.assemble(base_path, before ++ [insert_section] ++ rest)
    end
  end

  @doc """
  `contains_source?/2` returns true if at least one section from the given
  `LogicalFile` originates from the specified `source_path`.

  ## Examples
      iex> file = LogicalFile.read("test/support", "main.source")
      iex> assert not LogicalFile.contains_source?(file, "test/support/player.source")
      iex> assert LogicalFile.contains_source?(file, "test/support/main.source")
  """
  def contains_source?(%LogicalFile{sections: sections}, source_path) do
    Enum.any?(sections, fn {_range, section} -> section.source_path == source_path end)
  end

  @doc """
  `lines/1` returns a list of all lines in the `LogicalFile` in line number
  order.
  """
  def lines(%LogicalFile{} = file) do
    file
    |> sections_in_order()
    |> Enum.reduce([], fn section, lines -> lines ++ section.lines end)
  end

  @doc """
  `size/1` returns the number of lines in the `LogicalFile`.

  ## Examples
      iex> file = LogicalFile.read("test/support", "main.source")
      iex> 11 = LogicalFile.size(file)
  """
  def size(%LogicalFile{sections: sections}) do
    Enum.reduce(sections, 0, fn {_range, section}, size ->
      size + Section.size(section)
    end)
  end

  @doc """
  `last_line_number/1` returns the line number of the last line in the
  specified `LogicalFile`.

  ## Examples
      iex> alias LogicalFile.Section
      iex> file = LogicalFile.read("test/support", "main.source")
      iex> assert 11 = LogicalFile.last_line_number(file)
  """
  def last_line_number(%LogicalFile{} = file) do
    file
    |> sections_in_order()
    |> List.last()
    |> then(fn %Section{range: _lo..hi} -> hi end)
  end

  @doc """
  `update_line/3` replaces the content of line `lno` in the specified
  `LogicalFile` by passing the current contents of the line to the specified
  transformation function. This function is expected to return the new
  contents of the line.

  ## Examples
      iex> assert "                 " =
      ...>  LogicalFile.read("test/support", "main.source")
      ...>  |> LogicalFile.update_line(6, fn line -> String.duplicate(" ", String.length(line)) end)
      ...>  |> LogicalFile.line(6)
  """
  def update_line(%LogicalFile{sections: sections} = file, lno, fun) do
    updated_section =
      file
      |> section_including_line(lno)
      |> Section.update_line(lno, fun)

    %{file | sections: Map.put(sections, updated_section.range, updated_section)}
  end

  @doc """
  `section_including_line/2` returns the `Section` that contains the logical
  line `lno`.

  ## Examples
      iex> alias LogicalFile.Section
      iex> section1 = Section.new("test/support/main.source")
      iex> section2 = Section.new("test/support/include.source") |> Section.shift(-9)
      iex> map = LogicalFile.assemble("test/support", [section1, section2])
      iex> assert ^section1 = LogicalFile.section_including_line(map, section1.range.first)
      iex> assert ^section2 = LogicalFile.section_including_line(map, section2.range.first)
  """
  def section_including_line(%LogicalFile{} = file, lno) do
    file
    |> sections_in_order()
    |> Enum.find(fn %{range: range} -> lno in range end)
  end



  @doc """
  `resolve_line/2` takes a logical line number `logical_lno` and returns a
  tuple `{file, local_line_no}` representing the file and file line number
  that logical line represents.

  ## Examples
      iex> alias LogicalFile.Macros.Include
      iex> file = LogicalFile.read("test/support", "main.source")
      iex> assert {"test/support/main.source", 1} = LogicalFile.resolve_line(file, 1)
  """
  def resolve_line(%LogicalFile{} = file, logical_lno) do
    file
    |> section_including_line(logical_lno)
    |> Section.resolve_line(logical_lno)
  end

  # --- Utility functions ---

  @doc """
  `sections_to_map/1` takes a list of `Section`s and returns a `Map` whose
  keys are the logical line number ranges of the sections, mapped to the
  corresponding sections.
  """
  def sections_to_map(sections) do
    Enum.reduce(sections, %{}, fn section, map ->
      Map.put(map, section.range, section)
    end)
  end

  @doc """
  `sections_in_order/1` takes the `Section`s backing a `LogicalFile` and
  returns them as a list, ordered by the range of logical line numbers they
  represent.
  """
  def sections_in_order(%LogicalFile{sections: sections}) do
    sections
    |> Map.values()
    |> Enum.sort_by(fn %Section{range: range} -> range end)
  end

  @doc """
  `partition_sections/2` accepts a list of `Section`s and a logical
  line number `at_line` representing an insertion point. It  returns a tuple
  `{sections_before, insert_section, sections_after}` by finding the `Section`
  containing `at_line` and partitioning the remaining `Section`s around it.
  """
  def partition_sections(sections, at_line) when is_list(sections) do
    sections
    |> Enum.sort_by(fn section -> section.range end)
    |> Enum.split_while(fn section -> at_line not in section.range end)
    |> then(fn split ->
      case split do
        {_, []} -> {sections, nil, []}
        {before, [target | rest]} -> {before, target, rest}
      end
    end)
  end
end

defimpl String.Chars, for: LogicalFile do
  def to_string(%LogicalFile{} = file) do
    file
    |> LogicalFile.lines()
    |> Enum.join("\n")
  end
end