lib/paddle/schema_parser.ex

defmodule Paddle.SchemaParser do
  @moduledoc ~S"""
  Module used to parse *.schema files and generate Paddle classes.
  """

  require Logger

  for file <- Paddle.config(:schema_files) do
    @external_resource file
  end

  @definitions Paddle.config(:schema_files)
               |> Enum.flat_map(fn file ->
                 Logger.info("Loading #{file}")

                 {:ok, lexed, _num} =
                   file
                   |> File.read!()
                   |> String.to_charlist()
                   |> :schema_lexer.string()

                 {:ok, ast} = :schema_parser.parse(lexed)
                 ast
               end)

  @object_definitions Enum.filter(
                        @definitions,
                        fn {type, _attrs} -> type == :object_class end
                      )

  @attribute_definitions Enum.filter_map(
                           @definitions,
                           fn {type, _attrs} -> type == :attribute_type end,
                           fn {:attribute_type, attrs} -> Keyword.get(attrs, :name) end
                         ) ++
                           [["uid", "userid"]]

  @spec attributes(binary | [binary]) :: [atom]

  @doc ~S"""
  Get the attributes names of an object class or a list of object classes.

  Example:

      iex> Paddle.SchemaParser.attributes "account"
      [:description, :seeAlso, :l, :o, :ou, :host, :uid]

      iex> Paddle.SchemaParser.attributes ["posixAccount", "account"]
      [:userPassword, :loginShell, :gecos, :description, :cn, :uid, :uidNumber,
       :gidNumber, :homeDirectory, :seeAlso, :l, :o, :ou, :host]
  """
  def attributes(object_classes) do
    @object_definitions
    |> filter_definitions(object_classes)
    |> Enum.flat_map(&attributes_from/1)
    |> Enum.map(&replace_alias/1)
    |> Enum.map(&String.to_atom/1)
    |> Enum.uniq()
  end

  defp attributes_from({:object_class, description}) do
    mays(description) ++ musts(description)
  end

  @spec required_attributes(binary | [binary]) :: [atom]

  @doc ~S"""
  Get the required attributes names of an object class or a list of object
  classes.

  Example:

      iex> Paddle.SchemaParser.required_attributes "account"
      [:uid]
      iex> Paddle.SchemaParser.required_attributes ["posixAccount", "account"]
      [:cn, :uid, :uidNumber, :gidNumber, :homeDirectory]
  """
  def required_attributes(object_classes) do
    @object_definitions
    |> filter_definitions(object_classes)
    |> Enum.flat_map(&required_attributes_from/1)
    |> Enum.map(&replace_alias/1)
    |> Enum.map(&String.to_atom/1)
    |> Enum.uniq()
  end

  defp required_attributes_from({:object_class, description}) do
    musts(description)
  end

  defp mays(description) do
    description
    |> Keyword.get(:may, [])
  end

  defp musts(description) do
    description
    |> Keyword.get(:must, [])
  end

  defp filter_definitions(definitions, object_class) when is_binary(object_class) do
    filter_definitions(definitions, [object_class])
  end

  defp filter_definitions(definitions, object_classes) when is_list(object_classes) do
    object_classes =
      object_classes
      |> Enum.map(fn class -> {class, :notfound} end)
      |> Enum.into(%{})

    filter_definitions(definitions, object_classes, [])
  end

  defp filter_definitions([], object_classes, filtered) when is_map(object_classes) do
    not_found =
      object_classes
      |> Enum.filter_map(
        fn {_class, status} -> status == :notfound end,
        fn {class, _status} -> class end
      )

    case not_found do
      [] -> filtered
      _ -> raise "Missing object classe(s) definition(s): " <> Enum.join(not_found, ", ")
    end
  end

  defp filter_definitions([{:object_class, attrs} = class | rest], object_classes, filtered)
       when is_map(object_classes) do
    name = attrs |> Keyword.get(:name) |> hd

    if Map.has_key?(object_classes, name) do
      if object_classes[name] == :notfound do
        filter_definitions(rest, Map.put(object_classes, name, :found), [class | filtered])
      else
        IO.warn("Multiple definitions of the \"#{name}\" object class", [])
        filter_definitions(rest, object_classes, filtered)
      end
    else
      filter_definitions(rest, object_classes, filtered)
    end
  end

  defp replace_alias(field) do
    Enum.find_value(@attribute_definitions, fn aliases ->
      if field in aliases, do: hd(aliases)
    end) || field
  end
end