lib/zig/sema.ex

defmodule Zig.Sema do
  require EEx
  alias Zig.Manifest
  alias Zig.Type
  alias Zig.Type.Function
  alias Zig.Type.Manypointer
  alias Zig.Type.Struct

  @spec analyze_file!(module :: map, opts :: keyword) :: keyword
  # updates the per-function options to include the semantically understood type
  # information.  Also strips "auto" from the nif information to provide a finalized
  # keyword list of functions with their options.
  def analyze_file!(%{functions: functions, types: types}, opts) do
    Enum.each(functions, &validate_usable!(&1, types, opts))

    # `nifs` option could either be {:auto, keyword} which means that the full
    # list of functions should be derived from the semantic analysis, determining
    # which functions have `pub` declaration, with certain functions optionally
    # having their specification overloaded.
    #
    # it could also be just a list of functions with their specifications, in
    # which case those are the *only* functions that will be included.

    case Keyword.fetch!(opts, :nifs) do
      {:auto, specified_fns} ->
        default_options = Keyword.fetch!(opts, :default_options)

        # make sure all of the specified functions are indeed found by sema.
        Enum.each(specified_fns, fn
          {specified_fn, _} ->
            unless Enum.any?(functions, &(&1.name == specified_fn)) do
              raise CompileError,
                description:
                  "function #{specified_fn} not found in semantic analysis of functions."
            end
        end)

        Enum.map(
          functions,
          fn function = %{name: name} ->
            function_opts = Keyword.get(specified_fns, name, default_options)

            adjusted_function = adjust_raw(function, function_opts)

            {name, Keyword.put(function_opts, :type, adjusted_function)}
          end
        )

      selected_fns when is_list(selected_fns) ->
        Enum.map(selected_fns, fn
          {selected_fn, function_opts} ->
            function = Enum.find(functions, &(&1.name == selected_fn))

            unless function do
              raise CompileError,
                description:
                  "function #{selected_fn} not found in semantic analysis of functions."
            end

            adjusted_function = adjust_raw(function, function_opts)

            {selected_fn, Keyword.put(function_opts, :type, adjusted_function)}
        end)
    end
  end

  defp adjust_raw(function, opts) do
    case {function, opts[:raw]} do
      {_, nil} ->
        function

      # use this to identify that the function is a raw call.  `child` could be
      # either :term or :erl_nif_term.
      {%{params: [:env, _, %Manypointer{child: child}]}, integer} when is_integer(integer) ->
        %{function | params: List.duplicate(child, integer), arity: integer}

      {_, {:c, integer}} when is_integer(integer) ->
        %{function | params: List.duplicate(:erl_nif_term, integer), arity: integer}
    end
  end

  def run_sema!(file, module \\ nil, opts \\ [include_dir: []]) do
    case run_sema(file, module, opts) do
      {:ok, sema} -> sema
    end
  rescue
    e in Zig.CompileError ->
      reraise Zig.CompileError.to_error(e, opts), __STACKTRACE__
  end

  def run_sema(file, module \\ nil, opts \\ []) do
    opts = Keyword.put_new(opts, :include_dir, [])
    # TODO: integrate error handling here, and make this a common nexus for
    # file compilation
    with {:ok, sema_str} <- Zig.Command.run_sema(file, opts),
         {:ok, sema_json} <- Jason.decode(sema_str) do
      if opts[:dump_sema] do
        sema_json_pretty = Jason.encode!(sema_json, pretty: true)
        IO.puts([IO.ANSI.yellow(), sema_json_pretty, IO.ANSI.reset()])
      end

      # filter out any functions present in "ignore" clause.
      sema_json
      |> Map.update!("functions", &filter_ignores(&1, opts))
      |> module_to_types(module)
    end
  end

  defp filter_ignores(functions, opts) do
    ignores =
      opts
      |> Keyword.get(:ignore, [])
      |> List.wrap()
      |> Enum.map(&to_string/1)

    Enum.reject(functions, &(&1["name"] in ignores))
  end

  defp module_to_types(%{"functions" => functions, "types" => types, "decls" => decls}, module) do
    {:ok,
     %{
       functions: Enum.map(functions, &Function.from_json(&1, module)),
       types: Enum.map(types, &type_from_json(&1, module)),
       decls: Enum.map(decls, &const_from_json/1)
     }}
  end

  defp type_from_json(%{"name" => name, "type" => type}, module) do
    %{name: String.to_atom(name), type: Type.from_json(type, module)}
  end

  defp const_from_json(%{"name" => name, "type" => type}) do
    %{name: String.to_atom(name), type: String.to_atom(type)}
  end

  defp validate_usable!(function, types, opts) do
    with :ok <- validate_args(function, types),
         :ok <- validate_return(function.return),
         :ok <- validate_struct_pub(function.return, types, "return", function.name) do
      :ok
    else
      {:error, msg} ->
        file_path =
          opts
          |> Keyword.fetch!(:mod_file)
          |> Path.relative_to_cwd()

        %{location: {raw_line, _}} =
          opts
          |> Keyword.fetch!(:parsed)
          |> find_function(function.name)

        {file, line} =
          opts
          |> Keyword.fetch!(:manifest)
          |> Manifest.resolve(file_path, raw_line)

        raise CompileError,
          description: msg,
          file: file,
          line: line
    end
  end

  defp find_function(%{code: code}, function) do
    Enum.find(code, fn
      %Zig.Parser.Function{name: ^function} -> true
      %Zig.Parser.Const{name: ^function} -> true
      _ -> false
    end)
  end

  defp validate_args(function, types) do
    function.params
    |> Enum.with_index(1)
    |> Enum.reduce_while(:ok, fn
      {type, index}, :ok ->
        case validate_struct_pub(type, types, "argument #{index}", function.name) do
          :ok ->
            {:cont, :ok}

          error = {:error, _msg} ->
            {:halt, error}
        end
    end)
  end

  # explicitly, all return values that are permitted
  defp validate_return(type) do
    if Type.return_allowed?(type) do
      :ok
    else
      {:error, "functions returning #{type} cannot be nifs"}
    end
  end

  defp validate_struct_pub(%Struct{name: name}, types, location, function) do
    Enum.reduce_while(
      types,
      {:error, "struct `#{name}` (#{location} of function `#{function}`) is not a `pub` struct"},
      fn
        %{name: type_name}, error = {:error, _} ->
          if Atom.to_string(type_name) == name do
            {:halt, :ok}
          else
            {:cont, error}
          end
      end
    )
  end

  defp validate_struct_pub(_, _, _, _), do: :ok
end